@hyperframes/studio 0.8.107 → 0.8.108
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-BakGew45.js} +1 -1
- package/dist/assets/{index-D8GKc2D9.js → index-C5dyUHwk.js} +1 -1
- package/dist/assets/{index-CM1xA_jD.js → index-C842tcVa.js} +165 -165
- package/dist/assets/{index-zoqPmBF6.js → index-CPe7d_wO.js} +1 -1
- package/dist/assets/{index-lrT-gfHa.css → index-ncVJ5IVu.css} +1 -1
- package/dist/chunk-6LUNLTHR.js +611 -0
- package/dist/chunk-6LUNLTHR.js.map +1 -0
- package/dist/{chunk-4OZC426H.js → chunk-AOWVEVEU.js} +3 -503
- package/dist/chunk-AOWVEVEU.js.map +1 -0
- package/dist/{chunk-46XP7J3B.js → chunk-PQFEBX32.js} +5 -3
- package/dist/chunk-PQFEBX32.js.map +1 -0
- package/dist/{domEditingLayers-AQBDFNUC.js → domEditingLayers-JQPQGGT7.js} +3 -3
- package/dist/index.d.ts +11 -2
- package/dist/index.html +2 -2
- package/dist/index.js +12416 -11195
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/dist/styles.css +1 -1
- package/package.json +8 -8
- package/src/App.tsx +1 -0
- package/src/components/EditorShell.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/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/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/manualOffsetDrag.ts +11 -0
- package/src/components/editor/mediaAudioEdits.test.ts +59 -0
- package/src/components/editor/mediaAudioEdits.ts +40 -11
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +32 -4
- package/src/components/editor/propertyPanelAudioFxGroup.tsx +3 -1
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +7 -3
- 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/nle/AudioMeterStrip.test.tsx +53 -0
- package/src/components/nle/AudioMeterStrip.tsx +60 -2
- package/src/components/nle/useTimelineEditCallbacks.ts +4 -0
- package/src/contexts/TimelineEditContext.tsx +1 -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/usePersistentEditHistory.test.ts +9 -6
- package/src/hooks/usePersistentEditHistory.ts +1 -1
- package/src/hooks/useRazorSplit.ts +14 -1
- package/src/hooks/useRenderClipContent.test.ts +102 -0
- package/src/hooks/useRenderClipContent.ts +30 -23
- package/src/hooks/useTimelineEditing.ts +2 -2
- package/src/index.ts +1 -0
- package/src/player/components/AudibleVideoClipContent.tsx +29 -0
- package/src/player/components/ClipBadges.test.tsx +82 -0
- package/src/player/components/ClipBadges.tsx +67 -0
- package/src/player/components/ClipContextMenu.tsx +22 -0
- package/src/player/components/ClipPeakMarks.test.tsx +41 -0
- package/src/player/components/ClipPeakMarks.tsx +71 -0
- package/src/player/components/TimelineClip.tsx +2 -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/clipMenuAudioItems.test.tsx +125 -0
- package/src/player/components/clipMenuAudioItems.tsx +108 -0
- package/src/player/components/clipMenuDuck.test.ts +117 -0
- package/src/player/components/clipMenuDuck.ts +142 -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 +235 -0
- package/src/player/components/clipPeakRuns.test.ts +38 -0
- package/src/player/components/clipPeakRuns.ts +59 -0
- package/src/player/components/clipToolAttrs.test.ts +161 -0
- package/src/player/components/clipToolAttrs.ts +187 -0
- package/src/player/components/timelineCallbacks.ts +1 -0
- package/src/player/components/useClipToolState.ts +92 -0
- package/src/player/lib/timelineElementHelpers.ts +9 -0
- 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/previewRelativePath.ts +19 -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/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/{domEditingLayers-AQBDFNUC.js.map → domEditingLayers-JQPQGGT7.js.map} +0 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { afterEach, expect, it, vi } from "vitest";
|
|
3
|
+
import type { DomEditSelection } from "./domEditing";
|
|
4
|
+
import type { GestureState } from "./domEditOverlayGestures";
|
|
5
|
+
import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
|
|
6
|
+
import {
|
|
7
|
+
hasStudioPendingEdits,
|
|
8
|
+
revertNewestStudioPendingEdit,
|
|
9
|
+
} from "../../utils/studioPendingEdits";
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
document.body.innerHTML = "";
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const ref = <T>(current: T) => ({ current });
|
|
16
|
+
const pointer = (x: number, y: number) => ({
|
|
17
|
+
clientX: x,
|
|
18
|
+
clientY: y,
|
|
19
|
+
pointerId: 1,
|
|
20
|
+
button: 0,
|
|
21
|
+
preventDefault() {},
|
|
22
|
+
stopPropagation() {},
|
|
23
|
+
currentTarget: { setPointerCapture() {}, releasePointerCapture() {} },
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/** Drags a box without GSAP 100 px right and 60 px down; its save waits for `save`. */
|
|
27
|
+
function dragWithSaveRunning(save: Promise<void>) {
|
|
28
|
+
const element = document.createElement("div");
|
|
29
|
+
element.style.setProperty("translate", "40px 30px");
|
|
30
|
+
document.body.append(element);
|
|
31
|
+
const selection = { element, capabilities: { canApplyManualOffset: true } };
|
|
32
|
+
const handlers = createDomEditOverlayGestureHandlers({
|
|
33
|
+
selectionRef: ref(selection as unknown as DomEditSelection),
|
|
34
|
+
overlayRectRef: ref({ left: 0, top: 0, width: 240, height: 160, editScaleX: 1, editScaleY: 1 }),
|
|
35
|
+
boxRef: ref(document.createElement("div")),
|
|
36
|
+
overlayRef: ref(null),
|
|
37
|
+
iframeRef: ref(null),
|
|
38
|
+
gestureRef: ref<GestureState | null>(null),
|
|
39
|
+
rafPausedRef: ref(false),
|
|
40
|
+
onManualDragStartRef: ref(vi.fn()),
|
|
41
|
+
onBlockedMoveRef: ref(vi.fn()),
|
|
42
|
+
onPathOffsetCommitRef: ref(vi.fn(() => save)),
|
|
43
|
+
snapGuidesRef: ref(null),
|
|
44
|
+
groupGestureRef: ref(null),
|
|
45
|
+
blockedMoveRef: ref(null),
|
|
46
|
+
setOverlayRect: vi.fn(),
|
|
47
|
+
suppressNextBoxClickRef: ref(false),
|
|
48
|
+
hoverSelectionRef: ref(null),
|
|
49
|
+
onCanvasMouseDown: vi.fn(),
|
|
50
|
+
} as never);
|
|
51
|
+
expect(handlers.startGesture("drag", pointer(10, 10) as never)).toBe(true);
|
|
52
|
+
handlers.onPointerUp(pointer(110, 70) as never);
|
|
53
|
+
return element;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
it("a drag whose save is still running can be painted back at once, and shown again", async () => {
|
|
57
|
+
let saved!: () => void;
|
|
58
|
+
const element = dragWithSaveRunning(new Promise<void>((resolve) => (saved = resolve)));
|
|
59
|
+
const moved = element.style.getPropertyValue("translate");
|
|
60
|
+
expect(moved).not.toBe("40px 30px");
|
|
61
|
+
|
|
62
|
+
const showAgain = revertNewestStudioPendingEdit();
|
|
63
|
+
expect(element.style.getPropertyValue("translate")).toBe("40px 30px");
|
|
64
|
+
expect(revertNewestStudioPendingEdit()).toBeNull();
|
|
65
|
+
showAgain!();
|
|
66
|
+
expect(element.style.getPropertyValue("translate")).toBe(moved);
|
|
67
|
+
|
|
68
|
+
saved();
|
|
69
|
+
await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
|
|
70
|
+
expect(revertNewestStudioPendingEdit()).toBeNull();
|
|
71
|
+
});
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
import { computeDraggedGsapPosition } from "../../hooks/draggedGsapPosition";
|
|
14
14
|
import { gsapWritesBox, gsapWritesPosition } from "../../hooks/gsapRuntimeKeyframes";
|
|
15
15
|
import { readTranslatePx, UNREADABLE_TRANSLATE, writeTranslatePx } from "./plainTranslate";
|
|
16
|
+
import type { StudioEditRevert } from "../../utils/studioPendingEdits";
|
|
16
17
|
|
|
17
18
|
interface OffsetDragGsap {
|
|
18
19
|
set: (el: Element, vars: Record<string, number | string>) => void;
|
|
@@ -510,6 +511,16 @@ function restoreManualOffsetDragMember(member: ManualOffsetDragMember): void {
|
|
|
510
511
|
endStudioManualEditGesture(member.element, member.gestureToken);
|
|
511
512
|
}
|
|
512
513
|
|
|
514
|
+
/** Undo's live revert of a move: its members at gesture start. Null off the plain route, which GSAP draws. */
|
|
515
|
+
export function manualOffsetMoveRevert(members: ManualOffsetDragMember[]): StudioEditRevert | null {
|
|
516
|
+
if (!members.every((member) => member.plainTranslate)) return null;
|
|
517
|
+
return () => {
|
|
518
|
+
const shown = members.map((member) => captureStudioPathOffset(member.element));
|
|
519
|
+
for (const member of members) restoreStudioPathOffset(member.element, member.initialPathOffset);
|
|
520
|
+
return () => members.forEach((member, i) => restoreStudioPathOffset(member.element, shown[i]!));
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
|
|
513
524
|
/** Roll back a FAILED drag to the exact gesture-start state. */
|
|
514
525
|
export function restoreManualOffsetDragMembers(members: ManualOffsetDragMember[]): void {
|
|
515
526
|
for (const member of members) {
|
|
@@ -209,4 +209,63 @@ describe("buildKeepSoundCutoutEdit", () => {
|
|
|
209
209
|
const doc = parse(applyEdit(taken, edit));
|
|
210
210
|
expect(doc.getElementById("clip-audio-2")?.getAttribute("src")).toBe("assets/talk.mp4");
|
|
211
211
|
});
|
|
212
|
+
|
|
213
|
+
it("reads the authored src from the source, not the live element", () => {
|
|
214
|
+
const sub = source.replace('src="assets/talk.mp4"', 'src="../assets/a.mp4"');
|
|
215
|
+
const edit = buildKeepSoundCutoutEdit({
|
|
216
|
+
video: liveVideo(),
|
|
217
|
+
videoId: "clip",
|
|
218
|
+
target: { id: "clip" },
|
|
219
|
+
cutoutSrc: "assets/cut.webm",
|
|
220
|
+
});
|
|
221
|
+
const doc = parse(applyEdit(sub, edit));
|
|
222
|
+
expect(doc.getElementById("clip-audio")?.getAttribute("src")).toBe("../assets/a.mp4");
|
|
223
|
+
expect(doc.getElementById("clip")?.getAttribute("src")).toBe("assets/cut.webm");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("takes the first <source> child when the video has no src attribute", () => {
|
|
227
|
+
const withSource = source.replace(
|
|
228
|
+
/<video id="clip"([^>]*?) src="assets\/talk.mp4"([^>]*)><\/video>/,
|
|
229
|
+
'<video id="clip"$1$2><source src="assets/s.mp4" type="video/mp4"></video>',
|
|
230
|
+
);
|
|
231
|
+
const edit = buildKeepSoundCutoutEdit({
|
|
232
|
+
video: liveVideo(),
|
|
233
|
+
videoId: "clip",
|
|
234
|
+
target: { id: "clip" },
|
|
235
|
+
cutoutSrc: "assets/cut.webm",
|
|
236
|
+
});
|
|
237
|
+
const doc = parse(applyEdit(withSource, edit));
|
|
238
|
+
expect(doc.getElementById("clip-audio")?.getAttribute("src")).toBe("assets/s.mp4");
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("reports when the audio insert did not land", () => {
|
|
242
|
+
const edit = buildKeepSoundCutoutEdit({
|
|
243
|
+
video: liveVideo(),
|
|
244
|
+
videoId: "clip",
|
|
245
|
+
target: { id: "missing" },
|
|
246
|
+
cutoutSrc: "assets/cut.webm",
|
|
247
|
+
});
|
|
248
|
+
edit.prepareContent(source);
|
|
249
|
+
expect(edit.audioInserted()).toBe(false);
|
|
250
|
+
const ok = buildKeepSoundCutoutEdit({
|
|
251
|
+
video: liveVideo(),
|
|
252
|
+
videoId: "clip",
|
|
253
|
+
target: { id: "clip" },
|
|
254
|
+
cutoutSrc: "assets/cut.webm",
|
|
255
|
+
});
|
|
256
|
+
ok.prepareContent(source);
|
|
257
|
+
expect(ok.audioInserted()).toBe(true);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("copies loop onto the linked audio", () => {
|
|
261
|
+
const video = makeVideo({ id: "clip", src: "assets/talk.mp4", loop: "" });
|
|
262
|
+
const edit = buildKeepSoundCutoutEdit({
|
|
263
|
+
video,
|
|
264
|
+
videoId: "clip",
|
|
265
|
+
target: { id: "clip" },
|
|
266
|
+
cutoutSrc: "assets/cut.webm",
|
|
267
|
+
});
|
|
268
|
+
const doc = parse(applyEdit(source, edit));
|
|
269
|
+
expect(doc.getElementById("clip-audio")?.hasAttribute("loop")).toBe(true);
|
|
270
|
+
});
|
|
212
271
|
});
|
|
@@ -12,7 +12,9 @@ import { HF_AUDIO_GROUP_ATTR } from "@hyperframes/core/audio-groups";
|
|
|
12
12
|
import {
|
|
13
13
|
escapeHtmlAttribute,
|
|
14
14
|
findTagByTarget,
|
|
15
|
+
applyPatchByTarget,
|
|
15
16
|
readAttributeByTarget,
|
|
17
|
+
unescapeHtmlAttribute,
|
|
16
18
|
type PatchOperation,
|
|
17
19
|
type PatchTarget,
|
|
18
20
|
} from "../../utils/sourcePatcher";
|
|
@@ -24,6 +26,9 @@ import { generateId } from "../../utils/generateId";
|
|
|
24
26
|
const MEDIA_LINK_ATTR = "data-link";
|
|
25
27
|
const SOUND_KEPT_ON_LINKED_AUDIO_STAGE = "Background removed. Sound kept on a linked audio track.";
|
|
26
28
|
|
|
29
|
+
const SOUND_NOT_KEPT_MESSAGE =
|
|
30
|
+
"Background removed, but the sound could not be kept on a linked audio track. Undo to restore the original clip.";
|
|
31
|
+
|
|
27
32
|
const MOVED_SOUND_ATTRS = [
|
|
28
33
|
"data-volume",
|
|
29
34
|
HF_AUDIO_FADE_IN_ATTR,
|
|
@@ -131,6 +136,15 @@ function formatAttrs(attrs: Array<[string, string]>): string {
|
|
|
131
136
|
return attrs.map(([name, value]) => `${name}="${escapeHtmlAttribute(value)}"`).join(" ");
|
|
132
137
|
}
|
|
133
138
|
|
|
139
|
+
function readAuthoredSrc(source: string, target: PatchTarget): string {
|
|
140
|
+
const tag = findTagByTarget(source, target);
|
|
141
|
+
if (!tag) return "";
|
|
142
|
+
const own = /\bsrc=(["'])([^"']*)\1/.exec(tag.tag);
|
|
143
|
+
if (own?.[2]) return unescapeHtmlAttribute(own[2]);
|
|
144
|
+
const firstSource = /^\s*<source\b[^>]*?\bsrc=(["'])([^"']*)\1/i.exec(source.slice(tag.end + 1));
|
|
145
|
+
return firstSource?.[2] ? unescapeHtmlAttribute(firstSource[2]) : "";
|
|
146
|
+
}
|
|
147
|
+
|
|
134
148
|
function insertBeforeTarget(source: string, target: PatchTarget, markup: string): string {
|
|
135
149
|
const match = findTagByTarget(source, target);
|
|
136
150
|
if (!match) return source;
|
|
@@ -145,6 +159,7 @@ function insertBeforeTarget(source: string, target: PatchTarget, markup: string)
|
|
|
145
159
|
export interface KeepSoundCutoutEdit {
|
|
146
160
|
ops: PatchOperation[];
|
|
147
161
|
prepareContent: (source: string) => string;
|
|
162
|
+
audioInserted: () => boolean;
|
|
148
163
|
}
|
|
149
164
|
|
|
150
165
|
export function buildKeepSoundCutoutEdit(input: {
|
|
@@ -155,10 +170,11 @@ export function buildKeepSoundCutoutEdit(input: {
|
|
|
155
170
|
}): KeepSoundCutoutEdit {
|
|
156
171
|
const { video, target } = input;
|
|
157
172
|
const linkId = mintLinkId(video.ownerDocument);
|
|
158
|
-
|
|
173
|
+
let audioInserted = false;
|
|
159
174
|
|
|
160
175
|
const movedSound: Array<[string, string]> = [];
|
|
161
|
-
const
|
|
176
|
+
const cutoutSrcOp = htmlAttr("src", input.cutoutSrc);
|
|
177
|
+
const ops: PatchOperation[] = [htmlAttr("muted", "true"), dataAttr("has-audio", null)];
|
|
162
178
|
for (const name of MOVED_SOUND_ATTRS) {
|
|
163
179
|
const value = video.getAttribute(name);
|
|
164
180
|
if (value === null) continue;
|
|
@@ -174,6 +190,7 @@ export function buildKeepSoundCutoutEdit(input: {
|
|
|
174
190
|
ops.push(dataAttr(stripDataPrefix(MEDIA_LINK_ATTR), linkId));
|
|
175
191
|
|
|
176
192
|
const prepareContent = (source: string): string => {
|
|
193
|
+
const originalSrc = readAuthoredSrc(source, target);
|
|
177
194
|
const audioId = firstFreeName(
|
|
178
195
|
`${input.videoId || "video"}-audio`,
|
|
179
196
|
new Set(collectHtmlIds(source)),
|
|
@@ -193,10 +210,14 @@ export function buildKeepSoundCutoutEdit(input: {
|
|
|
193
210
|
[MEDIA_LINK_ATTR, linkId],
|
|
194
211
|
...movedSound,
|
|
195
212
|
];
|
|
196
|
-
|
|
213
|
+
if (video.hasAttribute("loop")) attrs.push(["loop", ""]);
|
|
214
|
+
const cut = applyPatchByTarget(source, target, cutoutSrcOp);
|
|
215
|
+
const inserted = insertBeforeTarget(cut, target, `<audio ${formatAttrs(attrs)}></audio>`);
|
|
216
|
+
audioInserted = inserted !== cut;
|
|
217
|
+
return inserted;
|
|
197
218
|
};
|
|
198
219
|
|
|
199
|
-
return { ops, prepareContent };
|
|
220
|
+
return { ops, prepareContent, audioInserted: () => audioInserted };
|
|
200
221
|
}
|
|
201
222
|
|
|
202
223
|
export interface MediaEditContext {
|
|
@@ -231,23 +252,31 @@ export async function commitCutout(
|
|
|
231
252
|
const isVideo = isVideoSelection(ctx.element);
|
|
232
253
|
const label = "Remove background";
|
|
233
254
|
const keep = isVideo && keepSound;
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
255
|
+
if (!keep) {
|
|
256
|
+
const applied = await ctx.commit(ctx.element, cutoutOps({ isVideo, cutoutSrc }), { label });
|
|
257
|
+
if (!applied) throw new Error("Couldn't apply the cutout");
|
|
258
|
+
return "Applied cutout";
|
|
259
|
+
}
|
|
260
|
+
const { landed, soundKept } = await commitKeepSoundCutout(ctx, cutoutSrc, label);
|
|
237
261
|
if (!landed) throw new Error("Couldn't apply the cutout");
|
|
238
|
-
|
|
262
|
+
if (!soundKept) throw new Error(SOUND_NOT_KEPT_MESSAGE);
|
|
263
|
+
return SOUND_KEPT_ON_LINKED_AUDIO_STAGE;
|
|
239
264
|
}
|
|
240
265
|
|
|
241
|
-
function commitKeepSoundCutout(
|
|
266
|
+
async function commitKeepSoundCutout(
|
|
242
267
|
ctx: MediaEditContext,
|
|
243
268
|
cutoutSrc: string,
|
|
244
269
|
label: string,
|
|
245
|
-
): Promise<boolean> {
|
|
270
|
+
): Promise<{ landed: boolean; soundKept: boolean }> {
|
|
246
271
|
const edit = buildKeepSoundCutoutEdit({
|
|
247
272
|
video: ctx.element.element,
|
|
248
273
|
videoId: ctx.element.id ?? null,
|
|
249
274
|
target: buildDomEditPatchTarget(ctx.element),
|
|
250
275
|
cutoutSrc,
|
|
251
276
|
});
|
|
252
|
-
|
|
277
|
+
const landed = await ctx.commit(ctx.element, edit.ops, {
|
|
278
|
+
label,
|
|
279
|
+
prepareContent: edit.prepareContent,
|
|
280
|
+
});
|
|
281
|
+
return { landed, soundKept: landed && edit.audioInserted() };
|
|
253
282
|
}
|
|
@@ -103,11 +103,11 @@ function mount(dataAttributes: Record<string, string>, alone = false, voices = 2
|
|
|
103
103
|
return { host, onSetAttributeQuiet, onSetAttributeLive };
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
function mountGroup(memberStart: number) {
|
|
106
|
+
function mountGroup(memberStart: number, memberTag: "audio" | "video" = "audio") {
|
|
107
107
|
const bus = document.createElement("hf-audio-group");
|
|
108
108
|
bus.id = "voiceover";
|
|
109
109
|
document.body.append(bus);
|
|
110
|
-
const member = document.createElement(
|
|
110
|
+
const member = document.createElement(memberTag);
|
|
111
111
|
member.id = "vo-1";
|
|
112
112
|
member.setAttribute("data-audio-group", "voiceover");
|
|
113
113
|
member.setAttribute("data-start", String(memberStart));
|
|
@@ -649,6 +649,16 @@ describe("AudioFxGroup dynamic carve", () => {
|
|
|
649
649
|
leaveShelf(host);
|
|
650
650
|
});
|
|
651
651
|
|
|
652
|
+
it("seeks a group audition to an audible video member's span", () => {
|
|
653
|
+
act(() =>
|
|
654
|
+
usePlayerStore.setState({ isPlaying: false, currentTime: 2, requestedSeekTime: null }),
|
|
655
|
+
);
|
|
656
|
+
const host = mountGroup(7, "video");
|
|
657
|
+
hoverPreset(host);
|
|
658
|
+
expect(store().requestedSeekTime).toBe(7);
|
|
659
|
+
leaveShelf(host);
|
|
660
|
+
});
|
|
661
|
+
|
|
652
662
|
it("leaves a transport the author started alone", () => {
|
|
653
663
|
// Stopping their playback because they passed over a preset would be the
|
|
654
664
|
// panel taking a decision nobody offered it.
|
|
@@ -1621,7 +1631,14 @@ describe("AudioFxGroup carve by default", () => {
|
|
|
1621
1631
|
describe("AudioFxGroup carve source list", () => {
|
|
1622
1632
|
/** Mount a bed alongside tracks named however the test needs. */
|
|
1623
1633
|
const mountWith = (
|
|
1624
|
-
tracks: {
|
|
1634
|
+
tracks: {
|
|
1635
|
+
id: string;
|
|
1636
|
+
src?: string;
|
|
1637
|
+
start?: string;
|
|
1638
|
+
duration?: string;
|
|
1639
|
+
tag?: "audio" | "video";
|
|
1640
|
+
muted?: boolean;
|
|
1641
|
+
}[],
|
|
1625
1642
|
bedAttrs: Record<string, string> = {},
|
|
1626
1643
|
) => {
|
|
1627
1644
|
const bed = document.createElement("audio");
|
|
@@ -1629,8 +1646,9 @@ describe("AudioFxGroup carve source list", () => {
|
|
|
1629
1646
|
for (const [k, v] of Object.entries(bedAttrs)) bed.setAttribute(`data-${k}`, v);
|
|
1630
1647
|
document.body.append(bed);
|
|
1631
1648
|
for (const t of tracks) {
|
|
1632
|
-
const el = document.createElement("audio");
|
|
1649
|
+
const el = document.createElement(t.tag ?? "audio");
|
|
1633
1650
|
el.id = t.id;
|
|
1651
|
+
if (t.muted) el.setAttribute("muted", "");
|
|
1634
1652
|
if (t.src) el.setAttribute("src", t.src);
|
|
1635
1653
|
if (t.start !== undefined) el.setAttribute("data-start", t.start);
|
|
1636
1654
|
if (t.duration !== undefined) el.setAttribute("data-duration", t.duration);
|
|
@@ -1660,6 +1678,16 @@ describe("AudioFxGroup carve source list", () => {
|
|
|
1660
1678
|
return { host, options, boxes, onSetAttributeQuiet };
|
|
1661
1679
|
};
|
|
1662
1680
|
|
|
1681
|
+
it("offers an audible video as a voice and never a muted one", () => {
|
|
1682
|
+
const { options } = mountWith([
|
|
1683
|
+
{ id: "a-roll", tag: "video" },
|
|
1684
|
+
{ id: "b-roll", tag: "video", muted: true },
|
|
1685
|
+
{ id: "narration" },
|
|
1686
|
+
]);
|
|
1687
|
+
expect(options).toContain("a-roll");
|
|
1688
|
+
expect(options).not.toContain("b-roll");
|
|
1689
|
+
});
|
|
1690
|
+
|
|
1663
1691
|
it("reads the one voice out instead of offering a picker with one entry", () => {
|
|
1664
1692
|
// A question with one answer is not a question. It is also the common case: a
|
|
1665
1693
|
// narration and a bed, with a couple of stings that cannot be the voice.
|
|
@@ -54,6 +54,7 @@ import {
|
|
|
54
54
|
resolveAudioGroups,
|
|
55
55
|
} from "@hyperframes/core/audio-groups";
|
|
56
56
|
import { useFxLevelling } from "./useFxLevelling.js";
|
|
57
|
+
import { isCarveVoiceElement } from "./useFxCarveGrouping.js";
|
|
57
58
|
|
|
58
59
|
function auditionSpan(startRaw: string | undefined, durationRaw: string | undefined) {
|
|
59
60
|
const start = Number.parseFloat(startRaw ?? "");
|
|
@@ -70,8 +71,9 @@ function auditionSpansFor(element: DomEditSelection): AuditionSpan[] {
|
|
|
70
71
|
if (element.tagName?.toLowerCase() !== HF_AUDIO_GROUP_TAG || !element.id) return [];
|
|
71
72
|
const doc = element.element?.ownerDocument;
|
|
72
73
|
if (!doc) return [];
|
|
73
|
-
return [...doc.querySelectorAll(`
|
|
74
|
+
return [...doc.querySelectorAll(`[${HF_AUDIO_GROUP_ATTR}]`)]
|
|
74
75
|
.filter((member) => member.getAttribute(HF_AUDIO_GROUP_ATTR) === element.id)
|
|
76
|
+
.filter(isCarveVoiceElement)
|
|
75
77
|
.flatMap((member) => {
|
|
76
78
|
const span = auditionSpan(
|
|
77
79
|
member.getAttribute("data-start") ?? undefined,
|
|
@@ -652,7 +652,12 @@ function makeHandlers() {
|
|
|
652
652
|
ops: PatchOperation[],
|
|
653
653
|
options: BatchOptions,
|
|
654
654
|
) => Promise<boolean>
|
|
655
|
-
>(async () =>
|
|
655
|
+
>(async (_selection, _ops, options) => {
|
|
656
|
+
options.prepareContent?.(
|
|
657
|
+
'<div data-composition-id="main"><video id="s1-bg" src="assets/intro-loop.mp4" data-start="2"></video></div>',
|
|
658
|
+
);
|
|
659
|
+
return true;
|
|
660
|
+
}),
|
|
656
661
|
};
|
|
657
662
|
}
|
|
658
663
|
|
|
@@ -871,14 +876,13 @@ describe.each<PanelKind>(["flat", "design"])("%s panel — cutout keeps the soun
|
|
|
871
876
|
const [selection, ops, options] = onSetAttributeBatch.mock.calls[0] ?? [];
|
|
872
877
|
expect(selection).toBe(element);
|
|
873
878
|
expect(ops).toEqual([
|
|
874
|
-
htmlOp("src", "assets/cut.webm"),
|
|
875
879
|
htmlOp("muted", "true"),
|
|
876
880
|
dataOp("has-audio", null),
|
|
877
881
|
dataOp("volume", null),
|
|
878
882
|
dataOp("link", "link"),
|
|
879
883
|
]);
|
|
880
884
|
const source =
|
|
881
|
-
'<div data-composition-id="main"><video id="s1-bg" src="assets/
|
|
885
|
+
'<div data-composition-id="main"><video id="s1-bg" src="assets/intro-loop.mp4" muted data-start="2" data-duration="3" data-track-index="0" data-link="link"></video></div>';
|
|
882
886
|
const prepared = options?.prepareContent?.(source) ?? "";
|
|
883
887
|
const audio = new DOMParser()
|
|
884
888
|
.parseFromString(prepared, "text/html")
|
|
@@ -11,7 +11,7 @@ import { ownsPlainKeys } from "../../utils/typingTarget";
|
|
|
11
11
|
import { acquireCanvasNudgeKeys } from "../../utils/canvasNudgeGate";
|
|
12
12
|
import {
|
|
13
13
|
addStudioPendingEditFlushListener,
|
|
14
|
-
|
|
14
|
+
beginStudioPendingEdit,
|
|
15
15
|
} from "../../utils/studioPendingEdits";
|
|
16
16
|
import type { DomEditSelection } from "./domEditing";
|
|
17
17
|
import {
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
applyManualOffsetNudgeDraft,
|
|
32
32
|
createManualOffsetDragMember,
|
|
33
33
|
endManualOffsetDragMembers,
|
|
34
|
+
manualOffsetMoveRevert,
|
|
34
35
|
restoreManualOffsetDragMembers,
|
|
35
36
|
type ManualOffsetDragMember,
|
|
36
37
|
} from "./manualOffsetDrag";
|
|
@@ -47,7 +48,7 @@ interface NudgeSession {
|
|
|
47
48
|
/** Accumulated delta of the burst, in composition px. */
|
|
48
49
|
accum: { x: number; y: number };
|
|
49
50
|
timer: ReturnType<typeof setTimeout> | null;
|
|
50
|
-
|
|
51
|
+
edit: ReturnType<typeof beginStudioPendingEdit>;
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
export interface UseDomEditNudgeParams {
|
|
@@ -161,11 +162,16 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
|
|
|
161
162
|
next: applyManualOffsetNudgeCommit(member, session.accum),
|
|
162
163
|
plainTranslate: member.plainTranslate,
|
|
163
164
|
}));
|
|
164
|
-
const commit = session.
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
165
|
+
const commit = session.edit.adopt(() =>
|
|
166
|
+
session.isGroup
|
|
167
|
+
? p.onGroupPathOffsetCommitRef.current(updates)
|
|
168
|
+
: p.onPathOffsetCommitRef.current(updates[0].selection, updates[0].next, {
|
|
169
|
+
plainTranslate: updates[0].plainTranslate,
|
|
170
|
+
}),
|
|
171
|
+
);
|
|
172
|
+
if (session.edit.reverted())
|
|
173
|
+
for (const member of session.members)
|
|
174
|
+
restoreStudioPathOffset(member.element, member.initialPathOffset);
|
|
169
175
|
saved = Promise.resolve(commit)
|
|
170
176
|
.catch(() => {
|
|
171
177
|
for (const member of session.members) {
|
|
@@ -177,7 +183,7 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
|
|
|
177
183
|
.finally(() => endManualOffsetDragMembers(session.members));
|
|
178
184
|
return saved;
|
|
179
185
|
} finally {
|
|
180
|
-
session.
|
|
186
|
+
session.edit.settle(saved);
|
|
181
187
|
}
|
|
182
188
|
};
|
|
183
189
|
const commitSessionRef = useRef(commitSession);
|
|
@@ -190,7 +196,7 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
|
|
|
190
196
|
if (session.timer) clearTimeout(session.timer);
|
|
191
197
|
restoreManualOffsetDragMembers(session.members);
|
|
192
198
|
endManualOffsetDragMembers(session.members);
|
|
193
|
-
session.
|
|
199
|
+
session.edit.settle();
|
|
194
200
|
}, [params.allowCanvasMovement]);
|
|
195
201
|
|
|
196
202
|
// Build drag members for the current target set — the same member snapshot a
|
|
@@ -217,9 +223,8 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
|
|
|
217
223
|
if (members.length === 0) return null;
|
|
218
224
|
// Same side effect a drag start has (pauses preview playback).
|
|
219
225
|
p.onManualDragStartRef.current?.();
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
return { members, isGroup, accum: { x: 0, y: 0 }, timer: null, endPendingEdit };
|
|
226
|
+
const edit = beginStudioPendingEdit(manualOffsetMoveRevert(members));
|
|
227
|
+
return { members, isGroup, accum: { x: 0, y: 0 }, timer: null, edit };
|
|
223
228
|
};
|
|
224
229
|
|
|
225
230
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
applyManualOffsetDragDraft,
|
|
12
12
|
endManualOffsetDragMembers,
|
|
13
13
|
restoreManualOffsetDragMembers,
|
|
14
|
+
manualOffsetMoveRevert,
|
|
14
15
|
} from "./manualOffsetDrag";
|
|
15
16
|
import { applyRotationDraft, restoreRotationDraft } from "./rotationDraft";
|
|
16
17
|
import {
|
|
@@ -57,6 +58,7 @@ import { logResize, logResizeMove, logResizeSettle } from "../../utils/resizeDeb
|
|
|
57
58
|
import { logDrag, logDragSettle, readDragPositions } from "../../utils/dragDebug";
|
|
58
59
|
import { createGroupDragMover } from "./groupDragMove";
|
|
59
60
|
import { DomEditSaveQueueOpenError } from "../../utils/domEditSaveQueue";
|
|
61
|
+
import { beginStudioPendingEdit } from "../../utils/studioPendingEdits";
|
|
60
62
|
|
|
61
63
|
function isTap(g: { startX: number; startY: number; travelled?: boolean }, e: React.PointerEvent) {
|
|
62
64
|
return (
|
|
@@ -344,7 +346,10 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
|
|
344
346
|
),
|
|
345
347
|
at: readDragPositions(groupG.members),
|
|
346
348
|
});
|
|
347
|
-
|
|
349
|
+
const groupEdit = beginStudioPendingEdit(manualOffsetMoveRevert(groupG.members));
|
|
350
|
+
const groupSaved = Promise.resolve(
|
|
351
|
+
groupEdit.adopt(() => opts.onGroupPathOffsetCommitRef.current(updates)),
|
|
352
|
+
)
|
|
348
353
|
.catch(() => {
|
|
349
354
|
for (const member of groupG.members) {
|
|
350
355
|
if (
|
|
@@ -363,6 +368,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
|
|
363
368
|
// so this is where a snap-back would show.
|
|
364
369
|
logDragSettle("settle", groupG.members);
|
|
365
370
|
});
|
|
371
|
+
groupEdit.settle(groupSaved);
|
|
366
372
|
return;
|
|
367
373
|
}
|
|
368
374
|
|
|
@@ -469,11 +475,15 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
|
|
469
475
|
box.style.left = `${nextBoxLeft}px`;
|
|
470
476
|
box.style.top = `${nextBoxTop}px`;
|
|
471
477
|
}
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
478
|
+
const member = g.pathOffsetMember;
|
|
479
|
+
const edit = beginStudioPendingEdit(manualOffsetMoveRevert([member]));
|
|
480
|
+
const saved = Promise.resolve(
|
|
481
|
+
edit.adopt(() =>
|
|
482
|
+
opts.onPathOffsetCommitRef.current(sel, finalOffset, {
|
|
483
|
+
altKey: e.altKey,
|
|
484
|
+
plainTranslate: member.plainTranslate,
|
|
485
|
+
}),
|
|
486
|
+
),
|
|
477
487
|
)
|
|
478
488
|
.catch(() => {
|
|
479
489
|
if (
|
|
@@ -485,6 +495,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
|
|
485
495
|
.finally(() => {
|
|
486
496
|
if (g.pathOffsetMember) endManualOffsetDragMembers([g.pathOffsetMember]);
|
|
487
497
|
});
|
|
498
|
+
edit.settle(saved);
|
|
488
499
|
} else {
|
|
489
500
|
opts.suppressNextBoxClickRef.current = true;
|
|
490
501
|
const finalSize = readStudioBoxSize(sel.element);
|
|
@@ -29,11 +29,11 @@ import {
|
|
|
29
29
|
resolveNextCarveSettings,
|
|
30
30
|
} from "./useFxCarveGrouping.js";
|
|
31
31
|
import { type HfAutomation } from "@hyperframes/core/audio-automation";
|
|
32
|
-
import { automationAttrValue, HF_AUDIO_AUTOMATION_ATTR } from "./propertyPanelAutomation";
|
|
33
32
|
import { trackCarveChanged } from "./audioFxTelemetry.js";
|
|
34
33
|
import type { DomEditSelection } from "./domEditingTypes";
|
|
35
34
|
import { usePlayerStore } from "../../player";
|
|
36
|
-
import {
|
|
35
|
+
import { dropCarveOutput, resolveCarveVoices, withoutCarveLanes } from "./carveOutput.js";
|
|
36
|
+
import { carveLanes, measureCarve, mintCarveNodes } from "./useFxCarveNodes.js";
|
|
37
37
|
import { readClipClock } from "./clipAudioClock.js";
|
|
38
38
|
import { spanOf } from "./propertyPanelAudioFxGroupUtils.js";
|
|
39
39
|
import type { AudioTrackOption } from "./propertyPanelFxCarveModule.js";
|
|
@@ -55,16 +55,6 @@ function carveAction(
|
|
|
55
55
|
return "strength";
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
/** Lanes belonging to nodes the carve generated, which a re-run replaces. */
|
|
59
|
-
function withoutCarveLanes(automation: HfAutomation, chain: HfAudioFxChain): HfAutomation {
|
|
60
|
-
const prefixes = chain.nodes.filter((n) => n.fromCarve && n.id).map((n) => `fx.${n.id}.`);
|
|
61
|
-
if (prefixes.length === 0) return automation;
|
|
62
|
-
return {
|
|
63
|
-
version: automation.version,
|
|
64
|
-
lanes: automation.lanes.filter((lane) => !prefixes.some((p) => lane.target.startsWith(p))),
|
|
65
|
-
};
|
|
66
|
-
}
|
|
67
|
-
|
|
68
58
|
/**
|
|
69
59
|
* Every setting here describes the filters, so changing one rebuilds them.
|
|
70
60
|
* There is no apply button: a carve naming a voice with no filters behind it
|
|
@@ -86,54 +76,6 @@ function carveNeedsReanalysis(
|
|
|
86
76
|
);
|
|
87
77
|
}
|
|
88
78
|
|
|
89
|
-
/**
|
|
90
|
-
* Every named voice that is actually there with something to decode. A source
|
|
91
|
-
* naming a deleted track is skipped rather than failing the whole analysis.
|
|
92
|
-
*
|
|
93
|
-
* Read out to plain values here rather than carrying elements around: it is
|
|
94
|
-
* what lets the src and the start be non-null by construction downstream
|
|
95
|
-
* instead of by assertion.
|
|
96
|
-
*/
|
|
97
|
-
function resolveCarveVoices(doc: Document, sources: readonly string[]): CarveClip[] {
|
|
98
|
-
const voices: CarveClip[] = [];
|
|
99
|
-
for (const id of sources) {
|
|
100
|
-
const el = doc.getElementById(id);
|
|
101
|
-
// By tag name, not `instanceof HTMLAudioElement`: these elements belong to
|
|
102
|
-
// the composition's iframe document, so the constructor they were made
|
|
103
|
-
// from is not this realm's and the instanceof is false for every one.
|
|
104
|
-
if (el?.tagName !== "AUDIO") continue;
|
|
105
|
-
const src = el.getAttribute("src");
|
|
106
|
-
if (!src) continue;
|
|
107
|
-
const clock = readClipClock((name) => el.getAttribute(name));
|
|
108
|
-
voices.push({ src, start: el.getAttribute("data-start"), clock });
|
|
109
|
-
}
|
|
110
|
-
return voices;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* What the carve generated is only justified by the voices it was measured
|
|
115
|
-
* from: switched off, or left naming none — every source deleted, say — there
|
|
116
|
-
* is nothing those filters are making room for. Left behind they keep dipping
|
|
117
|
-
* the bed with nothing in the panel to explain them.
|
|
118
|
-
*/
|
|
119
|
-
async function dropCarveOutput(
|
|
120
|
-
chain: HfAudioFxChain,
|
|
121
|
-
automation: HfAutomation,
|
|
122
|
-
onSetAttributeQuiet: (attr: string, value: string | null) => void | Promise<void>,
|
|
123
|
-
): Promise<void> {
|
|
124
|
-
const carriedOver = withoutCarveLanes(automation, chain);
|
|
125
|
-
if (carriedOver.lanes.length !== automation.lanes.length) {
|
|
126
|
-
await onSetAttributeQuiet(HF_AUDIO_AUTOMATION_ATTR, automationAttrValue(carriedOver) || null);
|
|
127
|
-
}
|
|
128
|
-
const kept = chain.nodes.filter((n) => !n.fromCarve);
|
|
129
|
-
if (kept.length !== chain.nodes.length) {
|
|
130
|
-
await onSetAttributeQuiet(
|
|
131
|
-
HF_AUDIO_FX_ATTR,
|
|
132
|
-
kept.length ? serializeAudioFxChain({ version: 1, nodes: kept }) : null,
|
|
133
|
-
);
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
79
|
export function useFxCarve(
|
|
138
80
|
element: DomEditSelection,
|
|
139
81
|
chain: HfAudioFxChain,
|
|
@@ -179,7 +121,7 @@ export function useFxCarve(
|
|
|
179
121
|
} => {
|
|
180
122
|
const doc = element.element?.ownerDocument;
|
|
181
123
|
if (!doc || !couldBeBed) return { sourceOptions: [], autoSourceIds: [] };
|
|
182
|
-
const others = Array.from(doc.querySelectorAll
|
|
124
|
+
const others = Array.from(doc.querySelectorAll("audio[id], video[id]")).filter(
|
|
183
125
|
(a) => a.id !== element.id,
|
|
184
126
|
);
|
|
185
127
|
// Only tracks that are actually playing while this bed is. A voice somewhere
|
|
@@ -54,6 +54,19 @@ describe("collectCarveCandidates", () => {
|
|
|
54
54
|
});
|
|
55
55
|
});
|
|
56
56
|
|
|
57
|
+
describe("collectCarveCandidates with video", () => {
|
|
58
|
+
it("offers an ungrouped audible video, not a muted one", () => {
|
|
59
|
+
const doc = previewDoc(`
|
|
60
|
+
<video id="a-roll"></video>
|
|
61
|
+
<video id="b-roll" muted></video>
|
|
62
|
+
<audio id="bed"></audio>
|
|
63
|
+
`);
|
|
64
|
+
const others = Array.from(doc.querySelectorAll("video[id]"));
|
|
65
|
+
const ids = collectCarveCandidates(doc, others, () => true, "bed").map((c) => c.id);
|
|
66
|
+
expect(ids).toEqual(["a-roll"]);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
57
70
|
describe("carverAgainst", () => {
|
|
58
71
|
// The far-end guard has to see through a GROUP source. A plural carve names a
|
|
59
72
|
// group — that is what the lint rule pushes authors toward — so matching raw
|