@hyperframes/studio 0.8.62 → 0.8.64
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-BMHU9-3w.js → hyperframes-player-pWkneUHC.js} +1 -1
- package/dist/assets/{index-Dg0Vjj_i.js → index-BEyJVAiH.js} +1 -1
- package/dist/assets/{index-DS0kLbuM.css → index-BnsYeuxA.css} +1 -1
- package/dist/assets/{index-DLYk_vu_.js → index-DLjjm1nC.js} +150 -150
- package/dist/assets/{index-Ce1SRT2i.js → index-RRyhgH2k.js} +1 -1
- package/dist/index.d.ts +44 -1
- package/dist/index.html +2 -2
- package/dist/index.js +4970 -5002
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +7 -7
- package/src/clipContentExports.test.tsx +54 -0
- package/src/components/nle/TimelinePane.tsx +8 -185
- package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +0 -49
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +4 -15
- package/src/components/ui/VideoFrameThumbnail.test.tsx +170 -0
- package/src/components/ui/VideoFrameThumbnail.tsx +10 -5
- package/src/hooks/useRenderClipContent.ts +1 -1
- package/src/hooks/useSdkSession.lifecycle.test.tsx +78 -1
- package/src/hooks/useSdkSession.ts +75 -13
- package/src/index.ts +10 -0
- package/src/player/components/AudioWaveform.tsx +1 -1
- package/src/player/components/CompositionThumbnail.test.ts +7 -0
- package/src/player/components/CompositionThumbnail.tsx +7 -3
- package/src/player/components/ImageThumbnail.tsx +1 -1
- package/src/player/components/TimelineClipJoins.test.ts +27 -0
- package/src/player/components/TimelineClipJoins.tsx +52 -0
- package/src/player/components/TimelineLanes.test.tsx +35 -0
- package/src/player/components/TimelineLanes.tsx +4 -5
- package/src/player/components/TimelineTransitionOverlays.tsx +36 -13
- package/src/player/components/timelineDragLanding.ts +1 -5
- package/src/player/components/timelineGaps.test.ts +3 -33
- package/src/player/components/timelineGaps.ts +4 -11
- package/src/player/components/timelineZones.test.ts +0 -5
- package/src/player/components/timelineZones.ts +2 -3
- package/src/styles/theme.css +3 -0
- package/src/telemetry/events.test.ts +0 -6
- package/src/telemetry/events.ts +0 -8
- package/src/components/nle/TimelinePane.test.ts +0 -63
|
@@ -440,7 +440,9 @@ describe("useSdkSession unavailable telemetry", () => {
|
|
|
440
440
|
// so this is what "the composition genuinely is not there" looks like on the
|
|
441
441
|
// wire — previously indistinguishable from a broken request. The shim and a
|
|
442
442
|
// real 0-byte file are the same response, hence the name.
|
|
443
|
-
|
|
443
|
+
// An older server sends no `missing` field, so the combined label stays —
|
|
444
|
+
// rather than guessing one of the two and quietly corrupting the series.
|
|
445
|
+
it("keeps the combined label when the server does not say which empty this is", async () => {
|
|
444
446
|
vi.stubGlobal(
|
|
445
447
|
"fetch",
|
|
446
448
|
vi.fn(async () => ({ ok: true, json: async () => ({ content: "" }) }) as Response),
|
|
@@ -459,6 +461,81 @@ describe("useSdkSession unavailable telemetry", () => {
|
|
|
459
461
|
await act(async () => root.unmount());
|
|
460
462
|
});
|
|
461
463
|
|
|
464
|
+
// `missing: true` is the route's shim — nothing resolved at that path.
|
|
465
|
+
it("reports a file the server could not find as absent", async () => {
|
|
466
|
+
vi.stubGlobal(
|
|
467
|
+
"fetch",
|
|
468
|
+
vi.fn(
|
|
469
|
+
async () => ({ ok: true, json: async () => ({ content: "", missing: true }) }) as Response,
|
|
470
|
+
),
|
|
471
|
+
);
|
|
472
|
+
const root = createRoot(document.createElement("div"));
|
|
473
|
+
await act(async () => root.render(<Probe projectId="project-a" />));
|
|
474
|
+
await flushAsyncEffects();
|
|
475
|
+
|
|
476
|
+
expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", {
|
|
477
|
+
stage: "read",
|
|
478
|
+
reason: "absent",
|
|
479
|
+
path_in_tree: null,
|
|
480
|
+
});
|
|
481
|
+
await act(async () => root.unmount());
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
// `missing: false` with empty content is a real 0-byte file on disk — a
|
|
485
|
+
// placeholder somebody created and has not written yet, not a bad path.
|
|
486
|
+
it("reports a real zero-byte file separately from an absent one", async () => {
|
|
487
|
+
vi.stubGlobal(
|
|
488
|
+
"fetch",
|
|
489
|
+
vi.fn(
|
|
490
|
+
async () => ({ ok: true, json: async () => ({ content: "", missing: false }) }) as Response,
|
|
491
|
+
),
|
|
492
|
+
);
|
|
493
|
+
const root = createRoot(document.createElement("div"));
|
|
494
|
+
await act(async () => root.render(<Probe projectId="project-a" />));
|
|
495
|
+
await flushAsyncEffects();
|
|
496
|
+
|
|
497
|
+
expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", {
|
|
498
|
+
stage: "read",
|
|
499
|
+
reason: "empty_file",
|
|
500
|
+
path_in_tree: null,
|
|
501
|
+
});
|
|
502
|
+
await act(async () => root.unmount());
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
// A 200 carrying HTML is an SPA fallback or a proxy answering in the route's
|
|
506
|
+
// place. Before this, `res.json()` rejected outside any catch and the outer
|
|
507
|
+
// catch filed it as `stage: "open"` — blaming the user's composition for a
|
|
508
|
+
// response the composition had nothing to do with.
|
|
509
|
+
it("reports a non-JSON 200 as a read failure, not a composition parse failure", async () => {
|
|
510
|
+
vi.stubGlobal(
|
|
511
|
+
"fetch",
|
|
512
|
+
vi.fn(
|
|
513
|
+
async () =>
|
|
514
|
+
({
|
|
515
|
+
ok: true,
|
|
516
|
+
headers: { get: () => "text/html; charset=utf-8" },
|
|
517
|
+
json: async () => {
|
|
518
|
+
throw new SyntaxError(`Unexpected token '<', "<!-- /*!"... is not valid JSON`);
|
|
519
|
+
},
|
|
520
|
+
}) as unknown as Response,
|
|
521
|
+
),
|
|
522
|
+
);
|
|
523
|
+
const root = createRoot(document.createElement("div"));
|
|
524
|
+
await act(async () => root.render(<Probe projectId="project-a" />));
|
|
525
|
+
await flushAsyncEffects();
|
|
526
|
+
|
|
527
|
+
expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", {
|
|
528
|
+
stage: "read",
|
|
529
|
+
reason: "invalid_json",
|
|
530
|
+
content_type: "text/html; charset=utf-8",
|
|
531
|
+
});
|
|
532
|
+
expect(trackMock).not.toHaveBeenCalledWith(
|
|
533
|
+
"sdk_session_unavailable",
|
|
534
|
+
expect.objectContaining({ stage: "open" }),
|
|
535
|
+
);
|
|
536
|
+
await act(async () => root.unmount());
|
|
537
|
+
});
|
|
538
|
+
|
|
462
539
|
// The graveyard-refuted fix's proposed next step: instrument, don't act.
|
|
463
540
|
// `path_in_tree` separates "genuinely not in the loaded tree" from
|
|
464
541
|
// "concurrent/duplicate open" without deciding anything on Studio's behalf.
|
|
@@ -35,16 +35,46 @@ import { addExternalFileReloadListener } from "./externalFileReloadBus";
|
|
|
35
35
|
* before it left the browser — CSP `connect-src`, Private Network Access, an
|
|
36
36
|
* extension rewriting `fetch` (rejects near-instantly, tab stays visible and
|
|
37
37
|
* alive). Telemetry before this change could not tell the two apart.
|
|
38
|
+
*
|
|
39
|
+
* `absent` and `empty_file` split what `absent_or_empty` could not: the route
|
|
40
|
+
* answers `content: ""` both for a file it cannot find (the `optional=1` shim)
|
|
41
|
+
* and for a real 0-byte one, so the single label covered a composition nobody
|
|
42
|
+
* has written yet AND a path that does not resolve on this server. The route
|
|
43
|
+
* now marks the shim with `missing: true`. A server without that field still
|
|
44
|
+
* lands in `absent_or_empty`, so the old series stays honest rather than
|
|
45
|
+
* silently folding into one of the new ones.
|
|
46
|
+
*
|
|
47
|
+
* Measured on 0.8.62 before the split: 75 tabs hit this class and not one of
|
|
48
|
+
* them ever landed an SDK edit afterwards — it is terminal for the tab, while
|
|
49
|
+
* the tab itself keeps playing back and navigating, so nothing surfaces.
|
|
50
|
+
*
|
|
51
|
+
* `invalid_json` is a 200 whose body is not JSON at all — in production, an
|
|
52
|
+
* HTML page (`Unexpected token '<', "<!-- /*!"...`), i.e. an SPA fallback or a
|
|
53
|
+
* proxy answering in the route's place. `res.json()` rejected outside any
|
|
54
|
+
* catch until now, so it escaped this function and the effect's outer catch
|
|
55
|
+
* filed it as `stage: "open"` — which asserts the COMPOSITION failed to parse.
|
|
56
|
+
* Third label to make that same wrong claim, after `network` (#4240) and the
|
|
57
|
+
* fetch-rejection split; this one is the response shape, not the composition.
|
|
38
58
|
*/
|
|
39
59
|
type ProjectFileReadFailure =
|
|
40
60
|
| { ok: false; reason: "unsafe_path" }
|
|
41
61
|
| { ok: false; reason: "http_error"; status: number; why?: string }
|
|
42
62
|
| { ok: false; reason: "missing_content" }
|
|
43
63
|
| { ok: false; reason: "absent_or_empty" }
|
|
64
|
+
| { ok: false; reason: "absent" }
|
|
65
|
+
| { ok: false; reason: "empty_file" }
|
|
66
|
+
| { ok: false; reason: "invalid_json"; contentType: string }
|
|
44
67
|
| { ok: false; reason: "network"; elapsedMs: number; hidden: boolean };
|
|
45
68
|
|
|
46
69
|
type ProjectFileReadResult = { ok: true; content: string } | ProjectFileReadFailure;
|
|
47
70
|
|
|
71
|
+
/** The three ways a 200 can carry no composition — old combined label plus its split. */
|
|
72
|
+
const EMPTY_READ_REASONS = new Set<ProjectFileReadFailure["reason"]>([
|
|
73
|
+
"absent_or_empty",
|
|
74
|
+
"absent",
|
|
75
|
+
"empty_file",
|
|
76
|
+
]);
|
|
77
|
+
|
|
48
78
|
/**
|
|
49
79
|
* Record a read that produced no usable content, and answer which project — if
|
|
50
80
|
* any — the failure identifies as unreachable.
|
|
@@ -73,12 +103,15 @@ function reportReadFailure(
|
|
|
73
103
|
reason: read.reason,
|
|
74
104
|
...(read.reason === "http_error" ? { status: read.status, why: read.why } : {}),
|
|
75
105
|
...(read.reason === "network" ? { elapsed_ms: read.elapsedMs, hidden: read.hidden } : {}),
|
|
76
|
-
|
|
106
|
+
...(read.reason === "invalid_json" ? { content_type: read.contentType } : {}),
|
|
107
|
+
// Only meaningful for the empty-read reasons — the graveyard-refuted "clear
|
|
77
108
|
// activeCompPath when it's not in the tree" fix's proposed next step,
|
|
78
109
|
// scoped to instrumentation only. `null` while the tree hasn't loaded
|
|
79
110
|
// yet: a false-negative there would read as "genuinely absent" when it is
|
|
80
|
-
// really "haven't looked".
|
|
81
|
-
|
|
111
|
+
// really "haven't looked". Carried on all three so the split keeps the
|
|
112
|
+
// signal that made it worth splitting: every measured case so far is
|
|
113
|
+
// `path_in_tree: true`, a file the tree lists and this read cannot get.
|
|
114
|
+
...(EMPTY_READ_REASONS.has(read.reason) ? { path_in_tree: pathInTree } : {}),
|
|
82
115
|
});
|
|
83
116
|
if (read.reason !== "http_error") return null;
|
|
84
117
|
return read.status === 404 ? projectId : null;
|
|
@@ -118,6 +151,30 @@ async function httpReadFailureWhy(res: Response): Promise<string | undefined> {
|
|
|
118
151
|
return typeof body?.why === "string" ? body.why : undefined;
|
|
119
152
|
}
|
|
120
153
|
|
|
154
|
+
interface ProjectFileBody {
|
|
155
|
+
content?: string;
|
|
156
|
+
/** Present only from a server that separates its shim from a real 0-byte file. */
|
|
157
|
+
missing?: boolean;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Decide what a 200 with a JSON body actually delivered.
|
|
162
|
+
*
|
|
163
|
+
* `optional=1` answers a missing file with 200 + `content: ""`, so a non-string
|
|
164
|
+
* content is a response shape we did not expect, not absence. An empty string
|
|
165
|
+
* parses into a session with no elements, which declines every edit wholesale —
|
|
166
|
+
* not a session worth opening — and `missing` is the route saying which of the
|
|
167
|
+
* two empties it was. Without that field (older server) the combined label
|
|
168
|
+
* stands rather than guessing one and corrupting the series.
|
|
169
|
+
*/
|
|
170
|
+
function classifyProjectFileBody(data: ProjectFileBody): ProjectFileReadResult {
|
|
171
|
+
if (typeof data.content !== "string") return { ok: false, reason: "missing_content" };
|
|
172
|
+
if (data.content !== "") return { ok: true, content: data.content };
|
|
173
|
+
if (data.missing === true) return { ok: false, reason: "absent" };
|
|
174
|
+
if (data.missing === false) return { ok: false, reason: "empty_file" };
|
|
175
|
+
return { ok: false, reason: "absent_or_empty" };
|
|
176
|
+
}
|
|
177
|
+
|
|
121
178
|
/**
|
|
122
179
|
* Read a project file's content (optional read — a missing file is not an
|
|
123
180
|
* error). Replaces the removed SDK http adapter's `read()` — the only thing
|
|
@@ -146,16 +203,21 @@ async function readProjectFileOptional(
|
|
|
146
203
|
const why = await httpReadFailureWhy(res);
|
|
147
204
|
return { ok: false, reason: "http_error", status: res.status, ...(why ? { why } : {}) };
|
|
148
205
|
}
|
|
149
|
-
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
206
|
+
// A 200 that is not JSON is a different failure from a JSON body missing its
|
|
207
|
+
// field: it means something other than this route answered — an SPA fallback
|
|
208
|
+
// or a proxy. Left unguarded, it rejected out of this function entirely and
|
|
209
|
+
// the effect's outer catch blamed the composition. See the type's comment.
|
|
210
|
+
let data: ProjectFileBody;
|
|
211
|
+
try {
|
|
212
|
+
data = (await res.json()) as ProjectFileBody;
|
|
213
|
+
} catch {
|
|
214
|
+
return {
|
|
215
|
+
ok: false,
|
|
216
|
+
reason: "invalid_json",
|
|
217
|
+
contentType: res.headers.get("content-type") ?? "",
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
return classifyProjectFileBody(data);
|
|
159
221
|
}
|
|
160
222
|
|
|
161
223
|
/**
|
package/src/index.ts
CHANGED
|
@@ -77,6 +77,16 @@ export {
|
|
|
77
77
|
export { TimelineProvider, useTimelineContext } from "./player/components/TimelineProvider";
|
|
78
78
|
export type { TimelineTheme } from "./player/components/timelineTheme";
|
|
79
79
|
|
|
80
|
+
// Clip content thumbnails: used by a host rendering its own timeline lane.
|
|
81
|
+
export { AudioWaveform } from "./player/components/AudioWaveform";
|
|
82
|
+
export type { AudioWaveformProps } from "./player/components/AudioWaveform";
|
|
83
|
+
export { ImageThumbnail } from "./player/components/ImageThumbnail";
|
|
84
|
+
export type { ImageThumbnailProps } from "./player/components/ImageThumbnail";
|
|
85
|
+
export { useRenderClipContent } from "./hooks/useRenderClipContent";
|
|
86
|
+
export type { UseRenderClipContentOptions } from "./hooks/useRenderClipContent";
|
|
87
|
+
export type { ThumbnailPriority } from "./player/lib/thumbnailScheduler";
|
|
88
|
+
export type { TimelineClipRenderContext } from "./player/components/TimelineTypes";
|
|
89
|
+
|
|
80
90
|
// Host overlays: draw over the preview in composition coordinates (see EditorShellProps.gestureOverlay)
|
|
81
91
|
export { usePreviewCompositionRect } from "./components/editor/usePreviewCompositionRect";
|
|
82
92
|
export type { PreviewCompositionRect } from "./components/editor/usePreviewCompositionRect";
|
|
@@ -4,7 +4,7 @@ import { useThumbnailLease } from "../../hooks/useThumbnailLease";
|
|
|
4
4
|
import { createThumbnailKey, type ThumbnailPriority } from "../lib/thumbnailScheduler";
|
|
5
5
|
import { decimatePeaks, loudnessToOpacity } from "./audioWaveformPeaks";
|
|
6
6
|
|
|
7
|
-
interface AudioWaveformProps {
|
|
7
|
+
export interface AudioWaveformProps {
|
|
8
8
|
audioUrl: string;
|
|
9
9
|
waveformUrl?: string;
|
|
10
10
|
label: string;
|
|
@@ -137,6 +137,13 @@ describe("CompositionThumbnail", () => {
|
|
|
137
137
|
const tiles = [...host.querySelectorAll("img")];
|
|
138
138
|
expect(tiles.length).toBeGreaterThan(0);
|
|
139
139
|
expect(tiles.every((tile) => !tile.classList.contains("hidden"))).toBe(true);
|
|
140
|
+
// Pictures read untinted by default, like video filmstrips; the theme tokens own any dimming.
|
|
141
|
+
expect(
|
|
142
|
+
tiles.every((tile) => tile.style.opacity === "var(--timeline-composition-thumbnail-opacity)"),
|
|
143
|
+
).toBe(true);
|
|
144
|
+
expect(tiles[0]?.parentElement?.parentElement?.style.mixBlendMode).toBe(
|
|
145
|
+
"var(--timeline-composition-thumbnail-blend)",
|
|
146
|
+
);
|
|
140
147
|
});
|
|
141
148
|
|
|
142
149
|
it("aborts its scheduled off-DOM image probe when unmounted", async () => {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { memo, useCallback, useMemo, useRef, useState } from "react";
|
|
1
|
+
import { memo, useCallback, useMemo, useRef, useState, type CSSProperties } from "react";
|
|
2
2
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
3
3
|
import { useThumbnailLease } from "../../hooks/useThumbnailLease";
|
|
4
4
|
import { createThumbnailKey, type ThumbnailPriority } from "../lib/thumbnailScheduler";
|
|
@@ -153,7 +153,11 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
|
|
|
153
153
|
{value && (
|
|
154
154
|
<div
|
|
155
155
|
className="absolute inset-0 flex"
|
|
156
|
-
style={{
|
|
156
|
+
style={{
|
|
157
|
+
animation: "hf-thumb-fade 200ms ease-out",
|
|
158
|
+
mixBlendMode:
|
|
159
|
+
"var(--timeline-composition-thumbnail-blend)" as CSSProperties["mixBlendMode"],
|
|
160
|
+
}}
|
|
157
161
|
>
|
|
158
162
|
{Array.from({ length: frameCount }, (_, index) => (
|
|
159
163
|
<div
|
|
@@ -166,7 +170,7 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
|
|
|
166
170
|
alt=""
|
|
167
171
|
draggable={false}
|
|
168
172
|
className="absolute inset-0 h-full w-full object-cover"
|
|
169
|
-
style={{ opacity:
|
|
173
|
+
style={{ opacity: "var(--timeline-composition-thumbnail-opacity)" }}
|
|
170
174
|
/>
|
|
171
175
|
</div>
|
|
172
176
|
))}
|
|
@@ -5,7 +5,7 @@ import { createThumbnailKey, type ThumbnailPriority } from "../lib/thumbnailSche
|
|
|
5
5
|
import { TIMELINE_VIEWPORT_BUDGETS } from "../lib/timelineViewportBudgets";
|
|
6
6
|
import { computeThumbnailStrip, probeImageAspect } from "./thumbnailUtils";
|
|
7
7
|
|
|
8
|
-
interface ImageThumbnailProps {
|
|
8
|
+
export interface ImageThumbnailProps {
|
|
9
9
|
imageSrc: string;
|
|
10
10
|
label: string;
|
|
11
11
|
labelColor: string;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { deriveTimelineClipJoins } from "./TimelineClipJoins";
|
|
3
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
4
|
+
|
|
5
|
+
const clip = (id: string, start: number, duration: number): TimelineElement => ({
|
|
6
|
+
id,
|
|
7
|
+
tag: "video",
|
|
8
|
+
start,
|
|
9
|
+
duration,
|
|
10
|
+
track: 0,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe("deriveTimelineClipJoins", () => {
|
|
14
|
+
it("finds joins in time order, whatever order the clips arrive in", () => {
|
|
15
|
+
expect(
|
|
16
|
+
deriveTimelineClipJoins([clip("c", 3, 1), clip("a", 0, 1.5), clip("b", 1.5, 1.5)]),
|
|
17
|
+
).toEqual([1.5, 3]);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("treats float noise as a join and a one-frame gap or an overlap as none", () => {
|
|
21
|
+
expect(deriveTimelineClipJoins([clip("a", 0, 0.1 + 0.2), clip("b", 0.3, 1)])).toEqual([
|
|
22
|
+
0.1 + 0.2,
|
|
23
|
+
]);
|
|
24
|
+
expect(deriveTimelineClipJoins([clip("a", 0, 1), clip("b", 1 + 1 / 60, 1)])).toEqual([]);
|
|
25
|
+
expect(deriveTimelineClipJoins([clip("a", 0, 1), clip("b", 0.8, 1)])).toEqual([]);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { CLIP_Y } from "./timelineLayout";
|
|
2
|
+
import { sortedLaneClips, TRACK_GAP_EPSILON_S } from "./timelineGaps";
|
|
3
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
4
|
+
|
|
5
|
+
/** Times where one clip on a row ends exactly where the next begins. */
|
|
6
|
+
export function deriveTimelineClipJoins(elements: readonly TimelineElement[]): number[] {
|
|
7
|
+
const sorted = sortedLaneClips(elements);
|
|
8
|
+
const joins: number[] = [];
|
|
9
|
+
for (let index = 1; index < sorted.length; index += 1) {
|
|
10
|
+
const previous = sorted[index - 1]!;
|
|
11
|
+
const end = previous.start + previous.duration;
|
|
12
|
+
if (Math.abs(sorted[index]!.start - end) < TRACK_GAP_EPSILON_S) joins.push(end);
|
|
13
|
+
}
|
|
14
|
+
return joins;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface TimelineClipJoinsProps {
|
|
18
|
+
elements: readonly TimelineElement[];
|
|
19
|
+
pixelsPerSecond: number;
|
|
20
|
+
rowHeight: number;
|
|
21
|
+
clipBarHeight?: number;
|
|
22
|
+
color: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A hairline in the row colour over each join, so touching clips read as two without moving either. */
|
|
26
|
+
export function TimelineClipJoins({
|
|
27
|
+
elements,
|
|
28
|
+
pixelsPerSecond,
|
|
29
|
+
rowHeight,
|
|
30
|
+
clipBarHeight,
|
|
31
|
+
color,
|
|
32
|
+
}: TimelineClipJoinsProps) {
|
|
33
|
+
const height = clipBarHeight ?? rowHeight - 2 * CLIP_Y;
|
|
34
|
+
return deriveTimelineClipJoins(elements).map((time) => (
|
|
35
|
+
<div
|
|
36
|
+
key={time}
|
|
37
|
+
data-timeline-clip-join=""
|
|
38
|
+
aria-hidden="true"
|
|
39
|
+
className="absolute pointer-events-none"
|
|
40
|
+
style={{
|
|
41
|
+
left: time * pixelsPerSecond,
|
|
42
|
+
top: CLIP_Y,
|
|
43
|
+
width: 1,
|
|
44
|
+
height,
|
|
45
|
+
transform: "translateX(-50%)",
|
|
46
|
+
background: color,
|
|
47
|
+
// Over an idle or hovered clip, under the diamonds (6) and a selected clip (10).
|
|
48
|
+
zIndex: 5,
|
|
49
|
+
}}
|
|
50
|
+
/>
|
|
51
|
+
));
|
|
52
|
+
}
|
|
@@ -462,3 +462,38 @@ describe("TimelineLanes clip thumbnails", () => {
|
|
|
462
462
|
act(() => view.root.unmount());
|
|
463
463
|
});
|
|
464
464
|
});
|
|
465
|
+
|
|
466
|
+
describe("TimelineLanes clip joins", () => {
|
|
467
|
+
const at = (id: string, start: number, duration: number): TimelineElement => ({
|
|
468
|
+
...element(id, TRACK_A),
|
|
469
|
+
start,
|
|
470
|
+
duration,
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it("draws one row-coloured hairline where clips touch, and leaves the clips where they are", () => {
|
|
474
|
+
const view = renderLanes({
|
|
475
|
+
elements: [at("clip-a", 0, 2), at("clip-b", 2, 1.5), at("clip-c", 4, 1)],
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
const joins = view.host.querySelectorAll<HTMLElement>("[data-timeline-clip-join]");
|
|
479
|
+
expect(joins).toHaveLength(1);
|
|
480
|
+
expect(joins[0]?.style.left).toBe("200px");
|
|
481
|
+
expect(joins[0]?.style.width).toBe("1px");
|
|
482
|
+
expect(joins[0]?.style.background).toBe(defaultTimelineTheme.rowBackground);
|
|
483
|
+
const clipB = view.host.querySelector<HTMLElement>('[data-el-id="clip-b"]');
|
|
484
|
+
expect(clipB?.style.left).toBe("200px");
|
|
485
|
+
expect(clipB?.style.width).toBe("150px");
|
|
486
|
+
act(() => view.root.unmount());
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it("draws no join while a clip is being moved, since the moved clip is drawn elsewhere", () => {
|
|
490
|
+
const clipA = at("clip-a", 0, 2);
|
|
491
|
+
const view = renderLanes({
|
|
492
|
+
elements: [clipA, at("clip-b", 2, 1.5)],
|
|
493
|
+
draggedClip: { element: clipA, started: true } as DraggedClipState,
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
expect(view.host.querySelectorAll("[data-timeline-clip-join]")).toHaveLength(0);
|
|
497
|
+
act(() => view.root.unmount());
|
|
498
|
+
});
|
|
499
|
+
});
|
|
@@ -32,7 +32,6 @@ import { queryTimelineClipIndex } from "../lib/timelineClipIndex";
|
|
|
32
32
|
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
|
33
33
|
import { timelineClipFocusId } from "./timelineNavigationIdentity";
|
|
34
34
|
import { useTimelineKeyboardActor } from "./useTimelineKeyboardActor";
|
|
35
|
-
import { deriveTimelineTransitionSeams } from "./timelineTransitionSeams";
|
|
36
35
|
import { TimelineTransitionOverlays } from "./TimelineTransitionOverlays";
|
|
37
36
|
|
|
38
37
|
export function TimelineLanes({
|
|
@@ -224,9 +223,6 @@ export function TimelineLanes({
|
|
|
224
223
|
// right only while it is collapsed and the row is nothing but bar.
|
|
225
224
|
const clipBarHeight = rowExpanded ? TRACK_H - 2 * CLIP_Y : undefined;
|
|
226
225
|
const automationElements = els.map(getPreviewElement);
|
|
227
|
-
const transitionSeams = deriveTimelineTransitionSeams(allTransitionElements).filter(
|
|
228
|
-
(seam) => seam.incoming.track === trackNum,
|
|
229
|
-
);
|
|
230
226
|
// Minted here because this is the only place that sees BOTH ends of
|
|
231
227
|
// the disclosure: the caret in the sticky header and the diamond lanes
|
|
232
228
|
// on the canvas. Keyed by display row, not by `trackNum`, which is a
|
|
@@ -555,7 +551,10 @@ export function TimelineLanes({
|
|
|
555
551
|
})
|
|
556
552
|
}
|
|
557
553
|
<TimelineTransitionOverlays
|
|
558
|
-
|
|
554
|
+
transitionElements={allTransitionElements}
|
|
555
|
+
track={trackNum}
|
|
556
|
+
rowElements={draggedClip?.started ? [] : automationElements}
|
|
557
|
+
rowBackground={rowBackground}
|
|
559
558
|
pixelsPerSecond={pps}
|
|
560
559
|
rowHeight={rowHeight}
|
|
561
560
|
clipBarHeight={clipBarHeight}
|
|
@@ -1,30 +1,53 @@
|
|
|
1
1
|
import { CLIP_Y } from "./timelineLayout";
|
|
2
2
|
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
|
3
|
-
import type {
|
|
3
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
4
|
+
import { deriveTimelineTransitionSeams } from "./timelineTransitionSeams";
|
|
4
5
|
import { TimelineTransitionBadge } from "./TimelineTransitionBadge";
|
|
6
|
+
import { TimelineClipJoins } from "./TimelineClipJoins";
|
|
5
7
|
|
|
6
8
|
interface TimelineTransitionOverlaysProps {
|
|
7
|
-
|
|
9
|
+
transitionElements: readonly TimelineElement[];
|
|
10
|
+
track: number;
|
|
11
|
+
rowElements: readonly TimelineElement[];
|
|
12
|
+
rowBackground: string;
|
|
8
13
|
pixelsPerSecond: number;
|
|
9
14
|
rowHeight: number;
|
|
10
15
|
clipBarHeight?: number;
|
|
11
16
|
}
|
|
12
17
|
|
|
18
|
+
/** What a row draws where its clips meet: a hairline at an exact join, a badge over a transition. */
|
|
13
19
|
export function TimelineTransitionOverlays({
|
|
14
|
-
|
|
20
|
+
transitionElements,
|
|
21
|
+
track,
|
|
22
|
+
rowElements,
|
|
23
|
+
rowBackground,
|
|
15
24
|
pixelsPerSecond,
|
|
16
25
|
rowHeight,
|
|
17
26
|
clipBarHeight,
|
|
18
27
|
}: TimelineTransitionOverlaysProps) {
|
|
19
28
|
const top = CLIP_Y + (clipBarHeight ?? rowHeight - 2 * CLIP_Y) / 2;
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
29
|
+
const seams = deriveTimelineTransitionSeams(transitionElements).filter(
|
|
30
|
+
(seam) => seam.incoming.track === track,
|
|
31
|
+
);
|
|
32
|
+
return (
|
|
33
|
+
<>
|
|
34
|
+
<TimelineClipJoins
|
|
35
|
+
elements={rowElements}
|
|
36
|
+
pixelsPerSecond={pixelsPerSecond}
|
|
37
|
+
rowHeight={rowHeight}
|
|
38
|
+
clipBarHeight={clipBarHeight}
|
|
39
|
+
color={rowBackground}
|
|
40
|
+
/>
|
|
41
|
+
{seams.map((seam) => (
|
|
42
|
+
<TimelineTransitionBadge
|
|
43
|
+
key={`${getTimelineElementIdentity(seam.outgoing)}-${getTimelineElementIdentity(seam.incoming)}`}
|
|
44
|
+
centerPx={seam.centerTime * pixelsPerSecond}
|
|
45
|
+
top={top}
|
|
46
|
+
widthPx={Math.min(Math.max(seam.duration * pixelsPerSecond, 24), 32)}
|
|
47
|
+
outgoingSrc={seam.outgoing.src}
|
|
48
|
+
incomingSrc={seam.incoming.src}
|
|
49
|
+
/>
|
|
50
|
+
))}
|
|
51
|
+
</>
|
|
52
|
+
);
|
|
30
53
|
}
|
|
@@ -37,15 +37,11 @@ export function layoutAfterTrackInsert(
|
|
|
37
37
|
} | null {
|
|
38
38
|
const { elements, trackOrder } = deps;
|
|
39
39
|
const editKey = keyOf(element);
|
|
40
|
-
// Expanded-child rows are synthetic host lanes, not source-file topology.
|
|
41
|
-
if (element.expandedParentStart != null) return null;
|
|
42
40
|
const targetTrack = insertTrackValue(trackOrder, insertRow);
|
|
43
41
|
// Foreign display rows and the opposite zone must not affect this topology.
|
|
44
42
|
const writableZone = classifyZone(element);
|
|
45
43
|
const writable = (src: TimelineElement): boolean =>
|
|
46
|
-
sameSourceFile(src, element) &&
|
|
47
|
-
classifyZone(src) === writableZone &&
|
|
48
|
-
src.expandedParentStart == null;
|
|
44
|
+
sameSourceFile(src, element) && classifyZone(src) === writableZone;
|
|
49
45
|
const topologyOrder = [...new Set(elements.filter(writable).map((e) => e.track))].sort(
|
|
50
46
|
(a, b) => a - b,
|
|
51
47
|
);
|
|
@@ -183,39 +183,9 @@ describe("trackHasGaps", () => {
|
|
|
183
183
|
});
|
|
184
184
|
});
|
|
185
185
|
|
|
186
|
-
describe("lane floor
|
|
187
|
-
|
|
188
|
-
...el(id, start, duration),
|
|
189
|
-
expandedParentStart: 16,
|
|
190
|
-
sourceFile: "scene.html",
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
it("laneGapFloor is 0 for ordinary lanes and the host window start for child lanes", () => {
|
|
186
|
+
describe("lane floor", () => {
|
|
187
|
+
it("laneGapFloor is always 0", () => {
|
|
194
188
|
expect(laneGapFloor([el("a", 0, 2)])).toBe(0);
|
|
195
|
-
expect(laneGapFloor([
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
it("compaction lands the first child at the HOST window start, never absolute 0", () => {
|
|
199
|
-
const lane = [child("c1", 18, 2), child("c2", 22, 2)];
|
|
200
|
-
expect(resolveAllTrackGaps(lane, undefined, laneGapFloor(lane))).toEqual([
|
|
201
|
-
{ key: "c1", newStart: 16 },
|
|
202
|
-
{ key: "c2", newStart: 18 },
|
|
203
|
-
]);
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
it("the leading gap starts at the floor for both close-one and the highlight intervals", () => {
|
|
207
|
-
const lane = [child("c1", 18, 2)];
|
|
208
|
-
const floor = laneGapFloor(lane);
|
|
209
|
-
expect(resolveTrackGapAt(lane, 17, undefined, floor)).toEqual({
|
|
210
|
-
gapStart: 16,
|
|
211
|
-
gapEnd: 18,
|
|
212
|
-
followingKeys: ["c1"],
|
|
213
|
-
});
|
|
214
|
-
expect(resolveAllGapIntervals(lane, undefined, floor)).toEqual([{ start: 16, end: 18 }]);
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
it("a child lane contiguous from its host start has no gaps", () => {
|
|
218
|
-
const lane = [child("c1", 16, 2), child("c2", 18, 2)];
|
|
219
|
-
expect(trackHasGaps(lane, undefined, laneGapFloor(lane))).toBe(false);
|
|
189
|
+
expect(laneGapFloor([])).toBe(0);
|
|
220
190
|
});
|
|
221
191
|
});
|
|
@@ -17,22 +17,15 @@ export const TRACK_GAP_EPSILON_S = 1e-3;
|
|
|
17
17
|
|
|
18
18
|
const keyOf = (e: TimelineElement) => e.key ?? e.id;
|
|
19
19
|
|
|
20
|
-
/**
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
* collision-fix a lane is always single-origin) it is the children's host
|
|
24
|
-
* window start (`expandedParentStart`): display times are host-absolute, so
|
|
25
|
-
* compacting toward absolute 0 would drag a child BEFORE its host's window
|
|
26
|
-
* and persist a wrong (even negative) local time.
|
|
27
|
-
*/
|
|
28
|
-
export function laneGapFloor(elements: readonly TimelineElement[]): number {
|
|
29
|
-
return Math.max(0, ...elements.map((e) => e.expandedParentStart ?? 0));
|
|
20
|
+
/** The lane's time ORIGIN — the earliest start a clip on this lane may take. */
|
|
21
|
+
export function laneGapFloor(_elements: readonly TimelineElement[]): number {
|
|
22
|
+
return 0;
|
|
30
23
|
}
|
|
31
24
|
export const round3 = (v: number) => Math.round(v * 1000) / 1000;
|
|
32
25
|
const endOf = (e: TimelineElement) => e.start + e.duration;
|
|
33
26
|
|
|
34
27
|
/** Lane clips sorted by start (key as a deterministic tie-break). */
|
|
35
|
-
function sortedLaneClips(elements: readonly TimelineElement[]): TimelineElement[] {
|
|
28
|
+
export function sortedLaneClips(elements: readonly TimelineElement[]): TimelineElement[] {
|
|
36
29
|
return [...elements].sort((a, b) => a.start - b.start || keyOf(a).localeCompare(keyOf(b)));
|
|
37
30
|
}
|
|
38
31
|
|
|
@@ -67,11 +67,6 @@ describe("isMainTrackElement", () => {
|
|
|
67
67
|
it("is false for an audio clip even on track 0 (audio-only project has no main track)", () => {
|
|
68
68
|
expect(isMainTrackElement(el("m", "audio", 0))).toBe(false);
|
|
69
69
|
});
|
|
70
|
-
|
|
71
|
-
it("is false for an inline-expanded sub-composition child on track 0", () => {
|
|
72
|
-
const child: TimelineElement = { ...el("c", "video", 0), expandedParentStart: 4 };
|
|
73
|
-
expect(isMainTrackElement(child)).toBe(false);
|
|
74
|
-
});
|
|
75
70
|
});
|
|
76
71
|
|
|
77
72
|
describe("normalizeToZones — CapCut-stable lanes follow the track-index (never z)", () => {
|
|
@@ -16,10 +16,9 @@ export function classifyZone(el: TimelineElement): TrackZone {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
/** The "main track" is a convention, not a schema field: the first
|
|
19
|
-
* visual-zone display lane, matched only when it actually holds a visual
|
|
20
|
-
* clip and isn't an inline-expanded sub-composition child. */
|
|
19
|
+
* visual-zone display lane, matched only when it actually holds a visual clip. */
|
|
21
20
|
export function isMainTrackElement(el: TimelineElement): boolean {
|
|
22
|
-
return el.track === 0 && classifyZone(el) === "visual"
|
|
21
|
+
return el.track === 0 && classifyZone(el) === "visual";
|
|
23
22
|
}
|
|
24
23
|
|
|
25
24
|
const keyOf = (el: TimelineElement) => el.key ?? el.id;
|
package/src/styles/theme.css
CHANGED
|
@@ -80,6 +80,9 @@
|
|
|
80
80
|
--timeline-clip-chip-audio-bg: rgba(25, 16, 38, 0.7);
|
|
81
81
|
--timeline-clip-chip-text: #ffffff;
|
|
82
82
|
--timeline-shortcut-bg: rgba(17, 23, 35, 0.84);
|
|
83
|
+
/* Composition and text clips draw their frame as it is, like video filmstrips; a host may dim them. */
|
|
84
|
+
--timeline-composition-thumbnail-opacity: 1;
|
|
85
|
+
--timeline-composition-thumbnail-blend: normal;
|
|
83
86
|
--timeline-thumbnail-shimmer: linear-gradient(
|
|
84
87
|
90deg,
|
|
85
88
|
rgba(255, 255, 255, 0.02) 0%,
|