@hyperframes/studio 0.8.107 → 0.8.109
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-h4mktSdm.js → hyperframes-player-CJYtikAy.js} +1 -1
- package/dist/assets/{index-lrT-gfHa.css → index-BQZBDqMs.css} +1 -1
- package/dist/assets/{index-zoqPmBF6.js → index-CMSgrT2F.js} +1 -1
- package/dist/assets/{index-CM1xA_jD.js → index-CYP6dMd7.js} +212 -210
- package/dist/assets/{index-D8GKc2D9.js → index-HTFVHUJ4.js} +1 -1
- package/dist/{chunk-NCUBAVGK.js → chunk-5VSXRGIX.js} +4 -4
- package/dist/chunk-5VSXRGIX.js.map +1 -0
- package/dist/chunk-6LUNLTHR.js +611 -0
- package/dist/chunk-6LUNLTHR.js.map +1 -0
- package/dist/{chunk-46XP7J3B.js → chunk-PQFEBX32.js} +5 -3
- package/dist/chunk-PQFEBX32.js.map +1 -0
- package/dist/{chunk-4OZC426H.js → chunk-QUXZVHOJ.js} +10 -504
- package/dist/chunk-QUXZVHOJ.js.map +1 -0
- package/dist/{domEditingLayers-AQBDFNUC.js → domEditingLayers-JQPQGGT7.js} +3 -3
- package/dist/index.d.ts +53 -4
- package/dist/index.html +2 -2
- package/dist/index.js +15746 -13173
- package/dist/index.js.map +1 -1
- package/dist/player.js +3 -3
- package/dist/styles.css +1 -1
- package/dist/ui.js +1 -1
- package/package.json +8 -8
- package/src/App.tsx +8 -11
- package/src/components/EditorShell.tsx +6 -0
- package/src/components/LinkedSelectionToggle.tsx +73 -0
- package/src/components/TimelineToolbar.test.tsx +31 -0
- package/src/components/TimelineToolbar.tsx +2 -0
- package/src/components/editor/CropPresetBar.tsx +69 -0
- package/src/components/editor/DomEditCropHandles.test.tsx +44 -0
- package/src/components/editor/DomEditCropHandles.tsx +123 -95
- package/src/components/editor/InlineTextCaret.test.tsx +150 -0
- package/src/components/editor/InlineTextCaret.tsx +211 -0
- package/src/components/editor/InlineTextToolbar.test.tsx +34 -31
- package/src/components/editor/InlineTextToolbar.tsx +7 -5
- package/src/components/editor/PropertyPanelFlat.tsx +4 -1
- package/src/components/editor/carveOutput.ts +54 -0
- package/src/components/editor/cropPresetStore.ts +33 -0
- package/src/components/editor/cropPresets.test.ts +41 -0
- package/src/components/editor/cropPresets.ts +24 -0
- package/src/components/editor/domEditInlineText.ts +6 -4
- package/src/components/editor/domEditInlineTextElement.test.ts +7 -0
- package/src/components/editor/domEditingLayers.test.ts +21 -0
- package/src/components/editor/domEditingLayers.ts +2 -0
- package/src/components/editor/dragUndoPaint.test.ts +71 -0
- package/src/components/editor/linkedSpeedEdits.test.tsx +164 -0
- package/src/components/editor/linkedSpeedEdits.ts +94 -0
- package/src/components/editor/manualOffsetDrag.ts +11 -0
- package/src/components/editor/mediaAudioEdits.test.ts +79 -4
- package/src/components/editor/mediaAudioEdits.ts +66 -24
- package/src/components/editor/mediaLinkEdits.test.ts +321 -0
- package/src/components/editor/mediaLinkEdits.ts +282 -0
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +32 -4
- package/src/components/editor/propertyPanelAudioFxGroup.tsx +3 -1
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +10 -5
- package/src/components/editor/propertyPanelMediaSection.tsx +6 -1
- package/src/components/editor/useDomEditNudge.ts +17 -12
- package/src/components/editor/useDomEditOverlayGestures.ts +17 -6
- package/src/components/editor/useFxCarve.ts +3 -61
- package/src/components/editor/useFxCarveGrouping.test.ts +13 -0
- package/src/components/editor/useFxCarveGrouping.ts +8 -3
- package/src/components/editor/useInlineTextEditing.tsx +11 -4
- package/src/components/editor/useVolumeAutomation.ts +29 -7
- package/src/components/nle/AudioMeterStrip.test.tsx +53 -0
- package/src/components/nle/AudioMeterStrip.tsx +60 -2
- package/src/components/nle/useTimelineEditCallbacks.ts +12 -0
- package/src/components/ui/Menu.test.tsx +3 -22
- package/src/components/ui/Menu.tsx +2 -2
- package/src/components/ui/Popover.tsx +2 -2
- package/src/components/ui/Select.tsx +2 -2
- package/src/components/ui/Tooltip.tsx +2 -2
- package/src/components/ui/mountHost.testHelpers.ts +8 -0
- package/src/components/ui/valueControls.test.tsx +2 -21
- package/src/contexts/TimelineEditContext.tsx +4 -0
- package/src/hooks/appHotkeysDispatch.ts +2 -1
- package/src/hooks/linkShortcuts.test.ts +70 -0
- package/src/hooks/linkShortcuts.ts +41 -0
- package/src/hooks/timelineLinkEditPlan.test.ts +113 -0
- package/src/hooks/timelineLinkEditPlan.ts +123 -0
- package/src/hooks/timelineMoveAdapter.ts +13 -0
- package/src/hooks/timelineTimingSync.test.ts +59 -0
- package/src/hooks/timelineTimingSync.ts +22 -3
- package/src/hooks/useAppHotkeys.ts +9 -0
- package/src/hooks/useEditHistoryActions.paint.test.tsx +41 -0
- package/src/hooks/useEditHistoryActions.ts +13 -4
- package/src/hooks/useElementPicker.test.ts +62 -13
- package/src/hooks/useElementPicker.ts +10 -2
- package/src/hooks/useFreezeFrame.test.ts +56 -0
- package/src/hooks/useFreezeFrame.ts +121 -0
- package/src/hooks/useGsapScriptCommits.test.tsx +84 -2
- package/src/hooks/useGsapScriptCommits.ts +23 -3
- package/src/hooks/usePersistentEditHistory.test.ts +9 -6
- package/src/hooks/usePersistentEditHistory.ts +1 -1
- package/src/hooks/usePreviewPersistence.history.test.tsx +76 -0
- package/src/hooks/usePreviewPersistence.ts +15 -0
- package/src/hooks/useRazorSplit.test.tsx +48 -0
- package/src/hooks/useRazorSplit.ts +26 -2
- package/src/hooks/useRenderClipContent.test.ts +108 -4
- package/src/hooks/useRenderClipContent.ts +33 -23
- package/src/hooks/useTimelineDeleteOps.ts +10 -4
- package/src/hooks/useTimelineEditGuard.ts +54 -0
- package/src/hooks/useTimelineEditing.ts +38 -57
- package/src/hooks/useTimelineLinkEditing.test.tsx +112 -0
- package/src/hooks/useTimelineLinkEditing.ts +153 -0
- package/src/index.ts +2 -0
- package/src/player/components/AudibleVideoClipContent.tsx +29 -0
- package/src/player/components/AudioWaveform.test.tsx +30 -3
- package/src/player/components/AudioWaveform.tsx +6 -7
- package/src/player/components/ClipBadges.test.tsx +82 -0
- package/src/player/components/ClipBadges.tsx +67 -0
- package/src/player/components/ClipContextMenu.groups.test.tsx +102 -0
- package/src/player/components/ClipContextMenu.test.tsx +10 -4
- package/src/player/components/ClipContextMenu.tsx +124 -77
- package/src/player/components/ClipPeakMarks.test.tsx +41 -0
- package/src/player/components/ClipPeakMarks.tsx +71 -0
- package/src/player/components/OutOfSyncBadge.test.tsx +91 -0
- package/src/player/components/OutOfSyncBadge.tsx +119 -0
- package/src/player/components/TimelineClip.test.tsx +17 -0
- package/src/player/components/TimelineClip.tsx +8 -0
- package/src/player/components/TimelineFxButton.test.tsx +2 -2
- package/src/player/components/TimelineTrackHeader.test.tsx +37 -0
- package/src/player/components/TimelineTrackHeader.tsx +12 -25
- package/src/player/components/audioClipLink.test.ts +228 -31
- package/src/player/components/audioClipLink.ts +72 -25
- package/src/player/components/clipMenuAudioItems.noShell.test.tsx +78 -0
- package/src/player/components/clipMenuAudioItems.test.tsx +168 -0
- package/src/player/components/clipMenuAudioItems.tsx +116 -0
- package/src/player/components/clipMenuDuck.test.ts +117 -0
- package/src/player/components/clipMenuDuck.ts +147 -0
- package/src/player/components/clipMenuLinkItems.test.ts +90 -0
- package/src/player/components/clipMenuLinkItems.tsx +121 -0
- package/src/player/components/clipMenuNormalize.test.ts +95 -0
- package/src/player/components/clipMenuNormalize.ts +96 -0
- package/src/player/components/clipMenuToolItems.test.tsx +139 -0
- package/src/player/components/clipMenuToolItems.tsx +233 -0
- package/src/player/components/clipPeakRuns.test.ts +38 -0
- package/src/player/components/clipPeakRuns.ts +59 -0
- package/src/player/components/clipSync.test.ts +67 -0
- package/src/player/components/clipSync.ts +80 -0
- package/src/player/components/clipToolAttrs.test.ts +161 -0
- package/src/player/components/clipToolAttrs.ts +187 -0
- package/src/player/components/linkLabelColor.test.ts +20 -0
- package/src/player/components/linkLabelColor.ts +24 -0
- package/src/player/components/timelineCallbacks.ts +12 -0
- package/src/player/components/timelineClipDragGestureLifecycle.ts +9 -0
- package/src/player/components/timelineClipDragTypes.ts +2 -0
- package/src/player/components/timelineClipGestureHandlers.ts +4 -1
- package/src/player/components/timelineLinkSelection.ts +23 -0
- package/src/player/components/useClipToolState.ts +92 -0
- package/src/player/components/useTimelineClipContextMenu.ts +2 -1
- package/src/player/components/useTimelineClipDrag.ts +21 -0
- package/src/player/components/useTimelineProviderState.tsx +3 -0
- package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
- package/src/player/hooks/timelinePlayerSync.ts +1 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +4 -1
- package/src/player/lib/timelineDOM.test.ts +31 -0
- package/src/player/lib/timelineDOM.ts +13 -0
- package/src/player/lib/timelineElementHelpers.ts +9 -0
- package/src/player/store/timelineElement.ts +12 -0
- package/src/styles/components.css +27 -11
- package/src/styles/theme.css +0 -1
- package/src/timelineChromeExports.test.tsx +7 -0
- package/src/utils/audioMeterMath.test.ts +37 -0
- package/src/utils/audioMeterMath.ts +9 -3
- package/src/utils/exportPeakCeiling.ts +1 -0
- package/src/utils/gsapSoftReload.test.ts +238 -2
- package/src/utils/gsapSoftReload.ts +89 -74
- package/src/utils/gsapUndoRestore.test.ts +4 -3
- package/src/utils/gsapUndoRestore.ts +15 -0
- package/src/utils/linkedClipPreferences.ts +34 -0
- package/src/utils/previewRelativePath.ts +19 -0
- package/src/utils/softReloadTargets.ts +128 -0
- package/src/utils/sourcePatcher.ts +1 -1
- package/src/utils/studioPendingEdits.test.ts +66 -0
- package/src/utils/studioPendingEdits.ts +45 -11
- package/src/utils/studioUiPreferences.ts +8 -0
- package/src/utils/timelineInspector.test.ts +33 -2
- package/src/utils/timelineInspector.ts +16 -4
- package/dist/chunk-46XP7J3B.js.map +0 -1
- package/dist/chunk-4OZC426H.js.map +0 -1
- package/dist/chunk-DI4LCJHL.js +0 -102
- package/dist/chunk-DI4LCJHL.js.map +0 -1
- package/dist/chunk-NCUBAVGK.js.map +0 -1
- /package/dist/{domEditingLayers-AQBDFNUC.js.map → domEditingLayers-JQPQGGT7.js.map} +0 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The attribute each clip-menu tool writes, and what a clip's attributes say is applied.
|
|
3
|
+
* Pure over strings so the menu, the badges and an agent's hand edits all agree.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
parseAudioFxChain,
|
|
8
|
+
serializeAudioFxChain,
|
|
9
|
+
type HfAudioFxChain,
|
|
10
|
+
} from "@hyperframes/core/audio-fx";
|
|
11
|
+
import { activeAudioFxPresetIds } from "@hyperframes/core/audio-fx-presets";
|
|
12
|
+
import { normalizeHfColorGrading } from "@hyperframes/core/color-grading";
|
|
13
|
+
import { parseRateLane } from "@hyperframes/core/speed-ramp";
|
|
14
|
+
import { applyPresetToChain } from "../../components/editor/useApplyAudioFxPreset";
|
|
15
|
+
|
|
16
|
+
export interface ClipToolChoice {
|
|
17
|
+
id: string;
|
|
18
|
+
label: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const VOICE_CHOICES: readonly ClipToolChoice[] = [
|
|
22
|
+
{ id: "voice-clean", label: "Clean" },
|
|
23
|
+
{ id: "voice-broadcast", label: "Broadcast" },
|
|
24
|
+
{ id: "voice-warm", label: "Warm" },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
export const CHARACTER_CHOICES: readonly ClipToolChoice[] = [
|
|
28
|
+
{ id: "telephone", label: "Telephone" },
|
|
29
|
+
{ id: "radio-am", label: "AM Radio" },
|
|
30
|
+
{ id: "megaphone", label: "Megaphone" },
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
export const LOOK_CHOICES: readonly ClipToolChoice[] = [
|
|
34
|
+
{ id: "warm-daylight", label: "Warm daylight" },
|
|
35
|
+
{ id: "clean-studio", label: "Clean studio" },
|
|
36
|
+
{ id: "vintage-wash", label: "Vintage wash" },
|
|
37
|
+
{ id: "mono-clean", label: "Mono" },
|
|
38
|
+
{ id: "deep-contrast", label: "Deep contrast" },
|
|
39
|
+
{ id: "home-movie-8mm", label: "Home movie" },
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
const VOICE_MENU_IDS = new Set([...VOICE_CHOICES, ...CHARACTER_CHOICES].map((c) => c.id));
|
|
43
|
+
|
|
44
|
+
const EMPTY_CHAIN: HfAudioFxChain = { version: 1, nodes: [] };
|
|
45
|
+
|
|
46
|
+
function parseChainOrEmpty(raw: string | null | undefined): HfAudioFxChain {
|
|
47
|
+
if (!raw) return EMPTY_CHAIN;
|
|
48
|
+
try {
|
|
49
|
+
return parseAudioFxChain(raw);
|
|
50
|
+
} catch {
|
|
51
|
+
return EMPTY_CHAIN;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function labelFor(choices: readonly ClipToolChoice[], id: string | null): string | null {
|
|
56
|
+
return choices.find((choice) => choice.id === id)?.label ?? null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function activeVoicePreset(rawChain: string | null | undefined): string | null {
|
|
60
|
+
const ids = activeAudioFxPresetIds(parseChainOrEmpty(rawChain));
|
|
61
|
+
return ids.find((id) => VOICE_MENU_IDS.has(id)) ?? null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function voicePresetLabel(id: string | null): string | null {
|
|
65
|
+
return labelFor(VOICE_CHOICES, id) ?? labelFor(CHARACTER_CHOICES, id);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The `data-fx-chain` after choosing a voice preset, or `null` to drop the attribute.
|
|
70
|
+
* Single choice: the other menu presets' nodes go; carve, leveller and hand-added nodes stay.
|
|
71
|
+
*/
|
|
72
|
+
export function chainWithVoicePreset(
|
|
73
|
+
rawChain: string | null | undefined,
|
|
74
|
+
presetId: string | null,
|
|
75
|
+
): string | null {
|
|
76
|
+
const chain = parseChainOrEmpty(rawChain);
|
|
77
|
+
const others = chain.nodes.filter(
|
|
78
|
+
(node) =>
|
|
79
|
+
!node.fromPreset || !VOICE_MENU_IDS.has(node.fromPreset) || node.fromPreset === presetId,
|
|
80
|
+
);
|
|
81
|
+
const kept: HfAudioFxChain = { ...chain, nodes: others };
|
|
82
|
+
const next = presetId ? (applyPresetToChain(kept, presetId, undefined) ?? kept) : kept;
|
|
83
|
+
return next.nodes.length > 0 ? serializeAudioFxChain(next) : null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function parseJson(raw: string | null | undefined): unknown {
|
|
87
|
+
if (!raw) return null;
|
|
88
|
+
try {
|
|
89
|
+
return JSON.parse(raw);
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function activeLook(rawGrading: string | null | undefined): string | null {
|
|
96
|
+
return normalizeHfColorGrading(parseJson(rawGrading))?.preset ?? null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function lookLabel(id: string | null): string | null {
|
|
100
|
+
return labelFor(LOOK_CHOICES, id);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function lookAttrValue(presetId: string | null): string | null {
|
|
104
|
+
return presetId ? JSON.stringify({ preset: presetId, intensity: 1 }) : null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
108
|
+
return typeof value === "object" && value !== null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function isDucked(rawCarve: string | null | undefined): boolean {
|
|
112
|
+
const carve = parseJson(rawCarve);
|
|
113
|
+
return isRecord(carve) && carve["enabled"] !== false;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function hasRateRamp(rawAutomation: string | null | undefined): boolean {
|
|
117
|
+
return parseRateLane(rawAutomation) !== null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const ZERO_INSET = /^inset\(\s*0(px|%)?\s*\)$/i;
|
|
121
|
+
|
|
122
|
+
export function hasCrop(clipPath: string | null | undefined): boolean {
|
|
123
|
+
const value = clipPath?.trim() ?? "";
|
|
124
|
+
return value !== "" && value !== "none" && !ZERO_INSET.test(value);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export type ClipBadgeKind = "link" | "look" | "voice" | "ramp" | "crop" | "ducked" | "volume";
|
|
128
|
+
|
|
129
|
+
export interface ClipBadge {
|
|
130
|
+
kind: ClipBadgeKind;
|
|
131
|
+
label: string;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** What a clip's attributes say is applied; the element's own attributes, as read off its node. */
|
|
135
|
+
export interface ClipToolState {
|
|
136
|
+
tag: string;
|
|
137
|
+
hasSound: boolean;
|
|
138
|
+
volume: number | null;
|
|
139
|
+
muted: boolean;
|
|
140
|
+
fxChain: string | null;
|
|
141
|
+
automation: string | null;
|
|
142
|
+
colorGrading: string | null;
|
|
143
|
+
clipPath: string | null;
|
|
144
|
+
carve: string | null;
|
|
145
|
+
link: string | null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function volumeBadge(state: ClipToolState): ClipBadge | null {
|
|
149
|
+
const isAudio = state.tag === "audio";
|
|
150
|
+
if (!isAudio && !state.hasSound) return null;
|
|
151
|
+
if (state.muted) return { kind: "volume", label: "Muted" };
|
|
152
|
+
const volume = state.volume ?? 1;
|
|
153
|
+
if (Math.abs(volume - 1) < 0.005) return null;
|
|
154
|
+
return { kind: "volume", label: `${Math.round(volume * 100)}%` };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Badges in the wireframe's order: look, voice, ramp, crop, ducked, volume. The link badge leads. */
|
|
158
|
+
export function readClipBadges(state: ClipToolState): ClipBadge[] {
|
|
159
|
+
const look = lookLabel(activeLook(state.colorGrading));
|
|
160
|
+
const voice = voicePresetLabel(activeVoicePreset(state.fxChain));
|
|
161
|
+
const volume = volumeBadge(state);
|
|
162
|
+
const badges: Array<ClipBadge | null> = [
|
|
163
|
+
state.link ? { kind: "link", label: "Linked" } : null,
|
|
164
|
+
look ? { kind: "look", label: look } : null,
|
|
165
|
+
voice ? { kind: "voice", label: `Voice: ${voice}` } : null,
|
|
166
|
+
hasRateRamp(state.automation) ? { kind: "ramp", label: "Ramp" } : null,
|
|
167
|
+
hasCrop(state.clipPath) ? { kind: "crop", label: "Crop" } : null,
|
|
168
|
+
isDucked(state.carve) ? { kind: "ducked", label: "Ducked" } : null,
|
|
169
|
+
volume,
|
|
170
|
+
];
|
|
171
|
+
return badges.filter((badge): badge is ClipBadge => badge !== null);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const MAX_VISIBLE_BADGES = 2;
|
|
175
|
+
|
|
176
|
+
/** The link badge is always shown; the rest cap at two, then `+N`. */
|
|
177
|
+
export function splitVisibleBadges(badges: readonly ClipBadge[]): {
|
|
178
|
+
visible: ClipBadge[];
|
|
179
|
+
hidden: ClipBadge[];
|
|
180
|
+
} {
|
|
181
|
+
const link = badges.filter((badge) => badge.kind === "link");
|
|
182
|
+
const rest = badges.filter((badge) => badge.kind !== "link");
|
|
183
|
+
return {
|
|
184
|
+
visible: [...link, ...rest.slice(0, MAX_VISIBLE_BADGES)],
|
|
185
|
+
hidden: rest.slice(MAX_VISIBLE_BADGES),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { LINK_LABEL_COLORS, linkLabelColor } from "./linkLabelColor";
|
|
3
|
+
|
|
4
|
+
describe("linkLabelColor", () => {
|
|
5
|
+
it("has no colour for an unlinked clip", () => {
|
|
6
|
+
expect(linkLabelColor(undefined)).toBeNull();
|
|
7
|
+
expect(linkLabelColor("")).toBeNull();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("walks the palette in lk-N order, wrapping", () => {
|
|
11
|
+
expect(linkLabelColor("lk-1")).toBe(LINK_LABEL_COLORS[0]);
|
|
12
|
+
expect(linkLabelColor("lk-2")).toBe(LINK_LABEL_COLORS[1]);
|
|
13
|
+
expect(linkLabelColor(`lk-${LINK_LABEL_COLORS.length + 1}`)).toBe(LINK_LABEL_COLORS[0]);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("is deterministic for hand-written ids", () => {
|
|
17
|
+
expect(linkLabelColor("talk-pair")).toBe(linkLabelColor("talk-pair"));
|
|
18
|
+
expect(LINK_LABEL_COLORS).toContain(linkLabelColor("talk-pair"));
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Link-group label colours; teal, violet, white and red belong to clips, selection and the sync badge. */
|
|
2
|
+
export const LINK_LABEL_COLORS = [
|
|
3
|
+
"var(--color-amber-500)",
|
|
4
|
+
"var(--color-sky-400)",
|
|
5
|
+
"var(--color-green-400)",
|
|
6
|
+
"var(--color-pink-400)",
|
|
7
|
+
"var(--color-orange-400)",
|
|
8
|
+
"var(--color-amber-300)",
|
|
9
|
+
] as const;
|
|
10
|
+
|
|
11
|
+
function stableIndex(link: string): number {
|
|
12
|
+
const numbered = /^lk-(\d+)$/.exec(link);
|
|
13
|
+
if (numbered) return Number(numbered[1]) - 1;
|
|
14
|
+
let hash = 0;
|
|
15
|
+
for (const char of link) hash = (hash * 31 + char.charCodeAt(0)) >>> 0;
|
|
16
|
+
return hash;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The same colour for every member of a link group, from its id alone. */
|
|
20
|
+
export function linkLabelColor(link: string | undefined): string | null {
|
|
21
|
+
if (!link) return null;
|
|
22
|
+
const count = LINK_LABEL_COLORS.length;
|
|
23
|
+
return LINK_LABEL_COLORS[((stableIndex(link) % count) + count) % count] ?? null;
|
|
24
|
+
}
|
|
@@ -39,6 +39,14 @@ export interface TimelineDropCallbacks {
|
|
|
39
39
|
) => Promise<void> | void;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
export type TimelineLinkEdit =
|
|
43
|
+
| { kind: "unlink"; elements: readonly TimelineElement[] }
|
|
44
|
+
| { kind: "link"; elements: readonly TimelineElement[] }
|
|
45
|
+
| { kind: "detach"; element: TimelineElement }
|
|
46
|
+
| { kind: "merge"; video: TimelineElement; audio: TimelineElement }
|
|
47
|
+
| { kind: "move-into-sync"; element: TimelineElement; start: number }
|
|
48
|
+
| { kind: "slip-into-sync"; element: TimelineElement; mediaStart: number };
|
|
49
|
+
|
|
42
50
|
export interface TimelineEditCallbacks {
|
|
43
51
|
onMoveElement?: (
|
|
44
52
|
element: TimelineElement,
|
|
@@ -115,9 +123,13 @@ export interface TimelineEditCallbacks {
|
|
|
115
123
|
label: string,
|
|
116
124
|
) => Promise<TimelineEditOutcome | void>;
|
|
117
125
|
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
|
|
126
|
+
onLinkEdit?: (edit: TimelineLinkEdit) => Promise<void> | void;
|
|
127
|
+
onDeleteElementOnly?: (element: TimelineElement) => Promise<void> | void;
|
|
118
128
|
onSplitElement?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
|
119
129
|
onRazorSplit?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
|
120
130
|
onRazorSplitAll?: (splitTime: number) => Promise<void> | void;
|
|
131
|
+
onFreezeFrame?: (element: TimelineElement, time: number) => Promise<void> | void;
|
|
132
|
+
onNotice?: (message: string, tone?: "error" | "info") => void;
|
|
121
133
|
onDeleteKeyframe?: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
|
|
122
134
|
onDeleteAllKeyframes?: (element: TimelineElement, animationId?: string) => void;
|
|
123
135
|
onMoveKeyframeToPlayhead?: (element: TimelineElement, keyframe: TimelineKeyframeTarget) => void;
|
|
@@ -70,6 +70,7 @@ interface TimelineClipDragGestureLifecycleInput {
|
|
|
70
70
|
onBlockedEditAttemptRef: RefObject<
|
|
71
71
|
((element: TimelineElement, intent: BlockedClipState["intent"]) => void) | undefined
|
|
72
72
|
>;
|
|
73
|
+
onLinkEditRef: RefObject<TimelineEditCallbacks["onLinkEdit"]>;
|
|
73
74
|
readZIndexRef: RefObject<((element: TimelineElement) => number) | undefined>;
|
|
74
75
|
onStackingPatchesRef: RefObject<
|
|
75
76
|
((patches: StackingPatch[]) => Promise<unknown> | void) | undefined
|
|
@@ -107,6 +108,7 @@ export function mountTimelineClipDragGestureLifecycle({
|
|
|
107
108
|
onResizeElementRef,
|
|
108
109
|
onResizeElementsRef,
|
|
109
110
|
onBlockedEditAttemptRef,
|
|
111
|
+
onLinkEditRef,
|
|
110
112
|
readZIndexRef,
|
|
111
113
|
onStackingPatchesRef,
|
|
112
114
|
refreshAfterLaneMoveRef,
|
|
@@ -228,6 +230,11 @@ export function mountTimelineClipDragGestureLifecycle({
|
|
|
228
230
|
if (drag && pointerMatchesGesture(event)) handleDragPointerMove(event, drag);
|
|
229
231
|
};
|
|
230
232
|
|
|
233
|
+
const unlinkAltEdited = (gesture: { element: TimelineElement; altKey?: boolean }) => {
|
|
234
|
+
if (!gesture.altKey || !gesture.element.link) return;
|
|
235
|
+
void onLinkEditRef.current?.({ kind: "unlink", elements: [gesture.element] });
|
|
236
|
+
};
|
|
237
|
+
|
|
231
238
|
const commitResizePointerUp = (
|
|
232
239
|
resize: ResizingClipState,
|
|
233
240
|
groupSession: TimelineGroupResizeSession | null,
|
|
@@ -271,6 +278,7 @@ export function mountTimelineClipDragGestureLifecycle({
|
|
|
271
278
|
]);
|
|
272
279
|
console.error("[Timeline] Failed to persist clip resize", error);
|
|
273
280
|
});
|
|
281
|
+
unlinkAltEdited(resize);
|
|
274
282
|
};
|
|
275
283
|
|
|
276
284
|
const finishBlockedPointerUp = (blocked: BlockedClipState) => {
|
|
@@ -293,6 +301,7 @@ export function mountTimelineClipDragGestureLifecycle({
|
|
|
293
301
|
onStackingPatches: onStackingPatchesRef.current,
|
|
294
302
|
refreshAfterLaneMove: refreshAfterLaneMoveRef.current,
|
|
295
303
|
});
|
|
304
|
+
unlinkAltEdited(drag);
|
|
296
305
|
};
|
|
297
306
|
|
|
298
307
|
/** Group gestures commit atomically: one missing member cancels the whole resize. */
|
|
@@ -33,6 +33,7 @@ export interface DraggedClipState {
|
|
|
33
33
|
snapTime: number | null;
|
|
34
34
|
snapType: TimelineSnapType | null;
|
|
35
35
|
started: boolean;
|
|
36
|
+
altKey?: boolean;
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
export interface ResizingClipState {
|
|
@@ -63,6 +64,7 @@ export interface ResizingClipState {
|
|
|
63
64
|
playbackStart?: number;
|
|
64
65
|
}[];
|
|
65
66
|
started: boolean;
|
|
67
|
+
altKey?: boolean;
|
|
66
68
|
}
|
|
67
69
|
|
|
68
70
|
export interface BlockedClipState {
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
import type { TimelineEditCapabilities } from "./timelineEditCapabilities";
|
|
9
9
|
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
|
10
10
|
import { CLIP_HANDLE_W } from "./timelineLayout";
|
|
11
|
+
import { selectClipWithLinks } from "./timelineLinkSelection";
|
|
11
12
|
import { SPLIT_BOUNDARY_EPSILON_S } from "../../utils/timelineElementSplit";
|
|
12
13
|
|
|
13
14
|
export interface ClipGestureDeps {
|
|
@@ -145,6 +146,7 @@ export function createClipGestureHandlers(
|
|
|
145
146
|
previewDuration: el.duration,
|
|
146
147
|
previewPlaybackStart: el.playbackStart,
|
|
147
148
|
started: false,
|
|
149
|
+
altKey: e.altKey,
|
|
148
150
|
});
|
|
149
151
|
};
|
|
150
152
|
|
|
@@ -191,6 +193,7 @@ export function createClipGestureHandlers(
|
|
|
191
193
|
snapTime: null,
|
|
192
194
|
snapType: null,
|
|
193
195
|
started: false,
|
|
196
|
+
altKey: e.altKey,
|
|
194
197
|
});
|
|
195
198
|
};
|
|
196
199
|
|
|
@@ -217,7 +220,7 @@ export function createClipGestureHandlers(
|
|
|
217
220
|
return;
|
|
218
221
|
}
|
|
219
222
|
// Clip selection is idempotent; empty timeline space owns deselection.
|
|
220
|
-
|
|
223
|
+
selectClipWithLinks(elementKey, e.altKey, setSelectedElementId);
|
|
221
224
|
onSelectElement?.(el);
|
|
222
225
|
};
|
|
223
226
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { usePlayerStore } from "../store/playerStore";
|
|
2
|
+
import { expandToLinkedMembers } from "./audioClipLink";
|
|
3
|
+
import { isLinkedSelectionOn } from "../../utils/linkedClipPreferences";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Select `key` the way a clip click does, then widen to link partners; Alt
|
|
7
|
+
* (or Linked Selection off) selects `key` alone, even inside a larger selection.
|
|
8
|
+
*/
|
|
9
|
+
export function selectClipWithLinks(
|
|
10
|
+
key: string,
|
|
11
|
+
altKey: boolean,
|
|
12
|
+
setSelectedElementId: (id: string) => void,
|
|
13
|
+
): void {
|
|
14
|
+
const state = usePlayerStore.getState();
|
|
15
|
+
if (altKey || !isLinkedSelectionOn()) {
|
|
16
|
+
state.setSelection([key], key);
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
setSelectedElementId(key);
|
|
20
|
+
const { selectedElementIds, elements } = usePlayerStore.getState();
|
|
21
|
+
const expanded = expandToLinkedMembers(selectedElementIds, elements);
|
|
22
|
+
if (expanded.size > selectedElementIds.size) state.setSelection(expanded, key);
|
|
23
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { HF_AUDIO_AUTOMATION_ATTR } from "@hyperframes/core/audio-automation";
|
|
3
|
+
import { HF_AUDIO_CARVE_ATTR } from "@hyperframes/core/audio-carve";
|
|
4
|
+
import { HF_AUDIO_FX_ATTR } from "@hyperframes/core/audio-fx";
|
|
5
|
+
import { HF_COLOR_GRADING_ATTR } from "@hyperframes/core/color-grading";
|
|
6
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
7
|
+
import { useLivePreviewIframe } from "../store/previewIframeStore";
|
|
8
|
+
import { useStudioShellContextOptional } from "../../contexts/StudioContext";
|
|
9
|
+
import { findTimelineElementInIframe } from "../../hooks/timelineEditingHelpers";
|
|
10
|
+
import { committedClipPath } from "../../components/editor/cropPresetStore";
|
|
11
|
+
import { isAudibleVideoNode } from "../lib/timelineElementHelpers";
|
|
12
|
+
import type { ClipToolState } from "./clipToolAttrs";
|
|
13
|
+
|
|
14
|
+
const LINK_ATTR = "data-link";
|
|
15
|
+
|
|
16
|
+
const WATCHED_ATTRS = [
|
|
17
|
+
HF_AUDIO_FX_ATTR,
|
|
18
|
+
HF_AUDIO_AUTOMATION_ATTR,
|
|
19
|
+
HF_AUDIO_CARVE_ATTR,
|
|
20
|
+
HF_COLOR_GRADING_ATTR,
|
|
21
|
+
LINK_ATTR,
|
|
22
|
+
"data-volume",
|
|
23
|
+
"data-has-audio",
|
|
24
|
+
"muted",
|
|
25
|
+
"style",
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
function readVolume(node: Element): number | null {
|
|
29
|
+
const volume = Number.parseFloat(node.getAttribute("data-volume") ?? "");
|
|
30
|
+
return Number.isFinite(volume) ? volume : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function toolStateFromNode(node: Element): ClipToolState {
|
|
34
|
+
const tag = node.tagName.toLowerCase();
|
|
35
|
+
return {
|
|
36
|
+
tag,
|
|
37
|
+
hasSound: tag === "audio" || isAudibleVideoNode(node),
|
|
38
|
+
volume: readVolume(node),
|
|
39
|
+
muted: node.hasAttribute("muted"),
|
|
40
|
+
fxChain: node.getAttribute(HF_AUDIO_FX_ATTR),
|
|
41
|
+
automation: node.getAttribute(HF_AUDIO_AUTOMATION_ATTR),
|
|
42
|
+
colorGrading: node.getAttribute(HF_COLOR_GRADING_ATTR),
|
|
43
|
+
clipPath: committedClipPath(node),
|
|
44
|
+
carve: node.getAttribute(HF_AUDIO_CARVE_ATTR),
|
|
45
|
+
link: node.getAttribute(LINK_ATTR),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function toolStateFromElement(el: TimelineElement): ClipToolState {
|
|
50
|
+
const tag = el.tag.trim().toLowerCase();
|
|
51
|
+
return {
|
|
52
|
+
tag,
|
|
53
|
+
hasSound: tag === "audio" || el.hasAudio === true,
|
|
54
|
+
volume: el.volume ?? null,
|
|
55
|
+
muted: el.muted === true,
|
|
56
|
+
fxChain: el.fxChain ?? null,
|
|
57
|
+
automation: el.automation ?? null,
|
|
58
|
+
colorGrading: null,
|
|
59
|
+
clipPath: null,
|
|
60
|
+
carve: null,
|
|
61
|
+
link: null,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Inline style changes every frame under a tween; only a changed reading re-renders. */
|
|
66
|
+
function keepIfUnchanged(prev: ClipToolState, next: ClipToolState): ClipToolState {
|
|
67
|
+
return JSON.stringify(prev) === JSON.stringify(next) ? prev : next;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A clip's tool attributes as the live preview node holds them, kept current as they change. */
|
|
71
|
+
export function useClipToolState(el: TimelineElement): ClipToolState {
|
|
72
|
+
const iframe = useLivePreviewIframe();
|
|
73
|
+
const activeCompPath = useStudioShellContextOptional()?.activeCompPath ?? null;
|
|
74
|
+
const [state, setState] = useState<ClipToolState>(() => toolStateFromElement(el));
|
|
75
|
+
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
const node = findTimelineElementInIframe(iframe, el, activeCompPath);
|
|
78
|
+
if (!node) {
|
|
79
|
+
setState(toolStateFromElement(el));
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const refresh = () => setState((prev) => keepIfUnchanged(prev, toolStateFromNode(node)));
|
|
83
|
+
refresh();
|
|
84
|
+
const view = node.ownerDocument.defaultView;
|
|
85
|
+
if (!view) return;
|
|
86
|
+
const observer = new view.MutationObserver(refresh);
|
|
87
|
+
observer.observe(node, { attributes: true, attributeFilter: WATCHED_ATTRS });
|
|
88
|
+
return () => observer.disconnect();
|
|
89
|
+
}, [iframe, el, activeCompPath]);
|
|
90
|
+
|
|
91
|
+
return state;
|
|
92
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useCallback } from "react";
|
|
2
2
|
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
3
3
|
import type { ClipContextMenuState } from "./TimelineProvider";
|
|
4
|
+
import { selectClipWithLinks } from "./timelineLinkSelection";
|
|
4
5
|
|
|
5
6
|
/** A clicked member of an active multi-selection keeps the whole group
|
|
6
7
|
* selected, so the context menu it opens acts on all of it. */
|
|
@@ -16,7 +17,7 @@ export function useClipContextMenu(
|
|
|
16
17
|
e.preventDefault();
|
|
17
18
|
const id = el.key ?? el.id;
|
|
18
19
|
if (!(selectedElementIds.size > 1 && selectedElementIds.has(id))) {
|
|
19
|
-
|
|
20
|
+
selectClipWithLinks(id, e.altKey, setSelectedElementId);
|
|
20
21
|
onSelectElement?.(el);
|
|
21
22
|
}
|
|
22
23
|
dismissGapMenu();
|
|
@@ -27,6 +27,8 @@ import type {
|
|
|
27
27
|
BlockedClipState,
|
|
28
28
|
} from "./timelineClipDragTypes";
|
|
29
29
|
import { getTimelineElementIndexes } from "../lib/timelineElementIndexes";
|
|
30
|
+
import { linkedGestureKeys } from "./audioClipLink";
|
|
31
|
+
import { isLinkedSelectionOn } from "../../utils/linkedClipPreferences";
|
|
30
32
|
import type { TimelineRowGeometry } from "./timelineLayout";
|
|
31
33
|
import {
|
|
32
34
|
mountTimelineClipDragGestureLifecycle,
|
|
@@ -65,6 +67,7 @@ interface UseTimelineClipDragInput {
|
|
|
65
67
|
) => Promise<void> | void;
|
|
66
68
|
onResizeElements?: NonNullable<TimelineEditCallbacks["onResizeElements"]>;
|
|
67
69
|
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedClipState["intent"]) => void;
|
|
70
|
+
onLinkEdit?: TimelineEditCallbacks["onLinkEdit"];
|
|
68
71
|
/** Seeks the preview; a trim shows the frame at its dragged edge. */
|
|
69
72
|
onSeek?: (time: number, options?: { keepPlaying?: boolean; follow?: boolean }) => void;
|
|
70
73
|
setShowPopover: (show: boolean) => void;
|
|
@@ -95,6 +98,7 @@ export function useTimelineClipDrag({
|
|
|
95
98
|
onResizeElement,
|
|
96
99
|
onResizeElements,
|
|
97
100
|
onBlockedEditAttempt,
|
|
101
|
+
onLinkEdit,
|
|
98
102
|
onSeek,
|
|
99
103
|
setShowPopover,
|
|
100
104
|
setRangeSelectionRef,
|
|
@@ -220,6 +224,13 @@ export function useTimelineClipDrag({
|
|
|
220
224
|
return;
|
|
221
225
|
}
|
|
222
226
|
beginGesture("drag", next.pointerId);
|
|
227
|
+
gestureSelectedKeysRef.current = linkedGestureKeys(
|
|
228
|
+
gestureSelectedKeysRef.current,
|
|
229
|
+
next.element,
|
|
230
|
+
elementsRef.current,
|
|
231
|
+
next.altKey === true,
|
|
232
|
+
isLinkedSelectionOn(),
|
|
233
|
+
);
|
|
223
234
|
publishDraggedClip(next);
|
|
224
235
|
},
|
|
225
236
|
[beginGesture, publishDraggedClip],
|
|
@@ -231,6 +242,13 @@ export function useTimelineClipDrag({
|
|
|
231
242
|
return;
|
|
232
243
|
}
|
|
233
244
|
beginGesture("resize", next.pointerId);
|
|
245
|
+
gestureSelectedKeysRef.current = linkedGestureKeys(
|
|
246
|
+
gestureSelectedKeysRef.current,
|
|
247
|
+
next.element,
|
|
248
|
+
elementsRef.current,
|
|
249
|
+
next.altKey === true,
|
|
250
|
+
isLinkedSelectionOn(),
|
|
251
|
+
);
|
|
234
252
|
publishResizingClip(next);
|
|
235
253
|
},
|
|
236
254
|
[beginGesture, publishResizingClip],
|
|
@@ -249,6 +267,8 @@ export function useTimelineClipDrag({
|
|
|
249
267
|
onMoveElementsRef.current = onMoveElements;
|
|
250
268
|
const onBlockedEditAttemptRef = useRef(onBlockedEditAttempt);
|
|
251
269
|
onBlockedEditAttemptRef.current = onBlockedEditAttempt;
|
|
270
|
+
const onLinkEditRef = useRef(onLinkEdit);
|
|
271
|
+
onLinkEditRef.current = onLinkEdit;
|
|
252
272
|
const onResizeElementRef = useRef(onResizeElement);
|
|
253
273
|
onResizeElementRef.current = onResizeElement;
|
|
254
274
|
const onResizeElementsRef = useRef(onResizeElements);
|
|
@@ -422,6 +442,7 @@ export function useTimelineClipDrag({
|
|
|
422
442
|
refreshAfterLaneMoveRef,
|
|
423
443
|
readZIndexRef,
|
|
424
444
|
onBlockedEditAttemptRef,
|
|
445
|
+
onLinkEditRef,
|
|
425
446
|
onResizeElementsRef,
|
|
426
447
|
onResizeElementRef,
|
|
427
448
|
onMoveElementsRef,
|
|
@@ -86,6 +86,7 @@ export function useTimelineProviderState({
|
|
|
86
86
|
onResizeElement,
|
|
87
87
|
onResizeElements,
|
|
88
88
|
onBlockedEditAttempt,
|
|
89
|
+
onLinkEdit,
|
|
89
90
|
onSplitElement,
|
|
90
91
|
onRazorSplitAll,
|
|
91
92
|
onDeleteKeyframe,
|
|
@@ -105,6 +106,7 @@ export function useTimelineProviderState({
|
|
|
105
106
|
const readOnlyPress = useTimelineReadOnlyPress();
|
|
106
107
|
const oneShotEdits = refuseWhenReadOnly(readOnlyPress, {
|
|
107
108
|
onBlockedEditAttempt,
|
|
109
|
+
onLinkEdit,
|
|
108
110
|
onRazorSplitAll,
|
|
109
111
|
onToggleTrackHidden: editContext.onToggleTrackHidden,
|
|
110
112
|
onTogglePropertyGroupKeyframe: editContext.onTogglePropertyGroupKeyframe,
|
|
@@ -217,6 +219,7 @@ export function useTimelineProviderState({
|
|
|
217
219
|
onResizeElement: pinnedOnResizeElement,
|
|
218
220
|
onResizeElements: pinnedOnResizeElements,
|
|
219
221
|
onBlockedEditAttempt: oneShotEdits.onBlockedEditAttempt,
|
|
222
|
+
onLinkEdit: oneShotEdits.onLinkEdit,
|
|
220
223
|
onSeek,
|
|
221
224
|
setShowPopover,
|
|
222
225
|
setRangeSelectionRef,
|
|
@@ -13,9 +13,15 @@ describe("timelineElementsChanged", () => {
|
|
|
13
13
|
["audio group hidden", { audioGroupHidden: true }],
|
|
14
14
|
["fade in", { fadeIn: 1 }],
|
|
15
15
|
["fade out", { fadeOut: 1 }],
|
|
16
|
+
["link", { link: "lk-1" }],
|
|
16
17
|
["source (re-pointed at its preview copy)", { src: "clip1.mp4?hf-proxy=h264" }],
|
|
17
18
|
])("sees a clip whose %s changed with no timing change", (_name, change) => {
|
|
18
19
|
expect(timelineElementsChanged([clip], [{ ...clip, ...change }])).toBe(true);
|
|
19
20
|
expect(timelineElementsChanged([{ ...clip, ...change }], [{ ...clip, ...change }])).toBe(false);
|
|
20
21
|
});
|
|
22
|
+
|
|
23
|
+
it("sees an unlink: the same clips re-derived without their link", () => {
|
|
24
|
+
const linked = { ...clip, link: "lk-1" };
|
|
25
|
+
expect(timelineElementsChanged([linked], [clip])).toBe(true);
|
|
26
|
+
});
|
|
21
27
|
});
|
|
@@ -15,6 +15,7 @@ import type { PlaybackAdapter, IframeWindow } from "../lib/playbackTypes";
|
|
|
15
15
|
import { readTimelineDurationFromDocument } from "../lib/timelineDOM";
|
|
16
16
|
import { buildMissingCompositionElements } from "../lib/timelineIframeHelpers";
|
|
17
17
|
import { acceptedRuntimeMessageFps } from "../lib/runtimeProtocol";
|
|
18
|
+
import { useLinkedClipPreferences } from "../../utils/linkedClipPreferences";
|
|
18
19
|
import {
|
|
19
20
|
buildTimelineElementsFromClips,
|
|
20
21
|
syncManifestTimeline,
|
|
@@ -196,8 +197,10 @@ export function useTimelineSyncCallbacks({
|
|
|
196
197
|
// at the authored root `data-duration` so a runtime that measures only the
|
|
197
198
|
// furthest clip end (shorter than the authored window) can't leave a stale,
|
|
198
199
|
// too-short total in the transport (the "0:44/0:40" bug).
|
|
200
|
+
const fps = acceptedRuntimeMessageFps(data);
|
|
201
|
+
useLinkedClipPreferences.getState().setCompositionFps(fps);
|
|
199
202
|
const newDuration = resolveTimelineTotalDuration({
|
|
200
|
-
manifestDurationSeconds: data.durationInFrames /
|
|
203
|
+
manifestDurationSeconds: data.durationInFrames / fps,
|
|
201
204
|
authoredRootDurationSeconds: readTimelineDurationFromDocument(iframeDoc),
|
|
202
205
|
});
|
|
203
206
|
syncManifestTimeline(
|
|
@@ -281,6 +281,37 @@ describe("parseTimelineFromDOM — hfId from data-hf-id", () => {
|
|
|
281
281
|
|
|
282
282
|
expect(element.hidden).toBe(true);
|
|
283
283
|
});
|
|
284
|
+
|
|
285
|
+
it("reads data-link and data-sync-origin on both the DOM and manifest paths", () => {
|
|
286
|
+
const doc = makeDoc(`
|
|
287
|
+
<div data-composition-id="root">
|
|
288
|
+
<video id="talk" class="clip" src="t.mp4" muted data-link="lk-1" data-sync-origin="lk-1" data-start="0" data-duration="5"></video>
|
|
289
|
+
</div>
|
|
290
|
+
`);
|
|
291
|
+
const fromDom = parseTimelineFromDOM(doc, 10).find((el) => el.domId === "talk");
|
|
292
|
+
expect(fromDom?.link).toBe("lk-1");
|
|
293
|
+
expect(fromDom?.syncOrigin).toBe("lk-1");
|
|
294
|
+
const element = createTimelineElementFromManifestClip({
|
|
295
|
+
clip: {
|
|
296
|
+
id: "talk",
|
|
297
|
+
label: "Talk",
|
|
298
|
+
kind: "element",
|
|
299
|
+
tagName: "video",
|
|
300
|
+
start: 0,
|
|
301
|
+
duration: 5,
|
|
302
|
+
track: 0,
|
|
303
|
+
compositionId: null,
|
|
304
|
+
parentCompositionId: null,
|
|
305
|
+
compositionSrc: null,
|
|
306
|
+
assetUrl: null,
|
|
307
|
+
},
|
|
308
|
+
fallbackIndex: 0,
|
|
309
|
+
doc,
|
|
310
|
+
hostEl: doc.getElementById("talk"),
|
|
311
|
+
});
|
|
312
|
+
expect(element.link).toBe("lk-1");
|
|
313
|
+
expect(element.syncOrigin).toBe("lk-1");
|
|
314
|
+
});
|
|
284
315
|
});
|
|
285
316
|
|
|
286
317
|
describe("group info cache", () => {
|