mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
Plural voiceover carve now targets a group instead of naming each clip: `resolveCarveSourceIds` (core `audioGroups.ts`) expands a group id to its current members at analysis time, so a clip added to the group later is covered without touching `sources`. The picker (`useFxCarve.ts`) offers a grouped voice as one option instead of one row per member, tests overlap as a union of member spans (a group overlaps the bed if ANY member does), and prefers a qualifying group over its individual members in `autoSourceIds`. Picking two or more ungrouped voice clips in the carve flow now mints a group behind them (`mintGroupId`, de-duped against every id in the document) and writes `data-audio-group` on each picked clip atomically, one undo entry — `createAudioGroupAndAssignMembers` in `timelineTrackVisibility.ts` copies `setElementsHidden`'s multi-target write shape. The DSP is untouched: `mixCarveSources` already sums multiple sources correctly (verified in the design doc's own investigation) — this only fixes the picker. New lint rule `audio_carve_ungrouped_sources` (`packages/lint/src/rules/ media.ts`, alongside `audio_volume_double_automation`) warns when a `data-fx-carve`'s `sources` names two or more plain clip ids instead of a group — the shape that silently rots when a clip is added. `/hyperframes- audio` states the same rule as an invariant, not a tip, with the grouped- narration HTML example from the design doc. The group-matching and auto-group logic (`withAutoGroupedSources`, `collectCarveCandidates`) is split into `useFxCarveGrouping.ts` — `useFxCarve.ts` was pushing past the 600-line cap. `resolveNextCarveSettings` is deliberately NOT an `async function`: wrapping it in one would force a microtask on every call, including the synchronous branch — the exact bug `withAutoGroupedSources`'s own sync-when-possible contract exists to avoid, and one caught via `propertyPanelAudioFxGroup.test.tsx` (10 failures) before fixing it back to a plain function the caller conditionally awaits. Also extracted `useEffectiveTimelineDuration` out of `App.tsx` and `useRemoveBackground` out of `StudioRightPanel.tsx` (both pushed past 600 lines from an added prop wire), and decomposed `useFxCarve.ts`'s picker IIFE to clear fallow's complexity gate. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
597 lines
28 KiB
TypeScript
597 lines
28 KiB
TypeScript
/**
|
|
* The voiceover carve: candidate voices, the relationship to another track's
|
|
* carve, and the measurement that turns a voice into peaking filters and
|
|
* ducking envelopes on this track.
|
|
*
|
|
* Split out of `propertyPanelAudioFxGroup.tsx`, which owned all of this before
|
|
* the file grew past a size where "the carve" was still one thing to read.
|
|
*/
|
|
|
|
import { useEffect } from "react";
|
|
import {
|
|
defaultAudioFxParams,
|
|
HF_AUDIO_FX_ATTR,
|
|
mintAudioFxNodeId,
|
|
serializeAudioFxChain,
|
|
type HfAudioFxChain,
|
|
type HfAudioFxNode,
|
|
} from "@hyperframes/core/audio-fx";
|
|
import {
|
|
analyseCarveBands,
|
|
analyseCarveDuck,
|
|
analyseCarveDynamics,
|
|
carveBandsToChain,
|
|
carveProfile,
|
|
clipsOverlap,
|
|
DEFAULT_CARVE,
|
|
mixCarveSources,
|
|
HF_AUDIO_CARVE_ATTR,
|
|
normalizeCarveSettings,
|
|
type HfCarveSettings,
|
|
} from "@hyperframes/core/audio-carve";
|
|
import { resolveAudioGroups, resolveCarveSourceIds } from "@hyperframes/core/audio-groups";
|
|
import {
|
|
collectCarveCandidates,
|
|
isPromiseLike,
|
|
resolveNextCarveSettings,
|
|
} from "./useFxCarveGrouping.js";
|
|
import {
|
|
fxAutomationTarget,
|
|
type HfAutomation,
|
|
type HfAutomationLane,
|
|
} from "@hyperframes/core/audio-automation";
|
|
import { automationAttrValue, HF_AUDIO_AUTOMATION_ATTR } from "./propertyPanelAutomation";
|
|
import { trackCarveChanged } from "./audioFxTelemetry.js";
|
|
import type { DomEditSelection } from "./domEditingTypes";
|
|
import { usePlayerStore } from "../../player";
|
|
import { clipStart, spanOf } from "./propertyPanelAudioFxGroupUtils.js";
|
|
import type { AudioTrackOption } from "./propertyPanelFxCarveModule.js";
|
|
|
|
/**
|
|
* Rate the carve source is decoded at. Analysis is self-consistent because it
|
|
* reads the decoded buffer's own rate, so this only has to be a sane audio rate.
|
|
*/
|
|
const DECODE_SAMPLE_RATE = 48000;
|
|
|
|
/** Whether some element's own carve attribute names `targetId` as a source. */
|
|
function carvesAgainst(other: HTMLElement, targetId: string): boolean {
|
|
try {
|
|
const raw = other.getAttribute(HF_AUDIO_CARVE_ATTR);
|
|
return Boolean(raw && normalizeCarveSettings(JSON.parse(raw)).sources.includes(targetId));
|
|
} catch {
|
|
// An unreadable carve on some other element says nothing about this one.
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Which carve setting actually moved, by comparing the two snapshots.
|
|
*
|
|
* On/off is checked before the rest: switching a carve off also strands its
|
|
* sources and strength, and reporting that as a "strength" change would be
|
|
* describing the wreckage instead of the decision.
|
|
*/
|
|
function carveAction(
|
|
before: HfCarveSettings | null,
|
|
after: HfCarveSettings | null,
|
|
): "enabled" | "disabled" | "strength" | "sources" {
|
|
if (!after?.enabled) return "disabled";
|
|
if (!before?.enabled) return "enabled";
|
|
if (before.sources.length !== after.sources.length) return "sources";
|
|
return "strength";
|
|
}
|
|
|
|
/** Lanes belonging to nodes the carve generated, which a re-run replaces. */
|
|
function withoutCarveLanes(automation: HfAutomation, chain: HfAudioFxChain): HfAutomation {
|
|
const prefixes = chain.nodes.filter((n) => n.fromCarve && n.id).map((n) => `fx.${n.id}.`);
|
|
if (prefixes.length === 0) return automation;
|
|
return {
|
|
version: automation.version,
|
|
lanes: automation.lanes.filter((lane) => !prefixes.some((p) => lane.target.startsWith(p))),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Every setting here describes the filters, so changing one rebuilds them.
|
|
* There is no apply button: a carve naming a voice with no filters behind it
|
|
* is a setting nobody applied, and the panel already knows everything it needs
|
|
* to. Picking the voice is what starts it; strength and dynamic re-derive what
|
|
* is already there. A carve with no source yet has nothing to analyse.
|
|
*/
|
|
function carveNeedsReanalysis(
|
|
before: HfCarveSettings | null,
|
|
after: HfCarveSettings | null,
|
|
): after is HfCarveSettings {
|
|
if (!after?.enabled || after.sources.length === 0) return false;
|
|
if (!before || !before.enabled) return true;
|
|
// Switching it back on is a change like any other: the filters went with the
|
|
// switch, so there is nothing left to hear until they are rebuilt. Without
|
|
// this, On restored the setting and left the bed uncarved.
|
|
return before.sources.join(" |