fix(studio): restore zero-strength revive, keyboard hold, and HDR detail in flat Grade

This commit is contained in:
Vance Ingalls
2026-07-14 15:51:55 -07:00
parent 8564ea4c92
commit 8a25f21cd6
2 changed files with 246 additions and 7 deletions
@@ -103,6 +103,110 @@ describe("FlatColorGradingAccessory", () => {
expect(resetGrading).toHaveBeenCalledTimes(1);
act(() => root.unmount());
});
it("shows the runtime status message as visible text next to the dot, not only as a title", () => {
const { host, root } = renderInto(
<FlatColorGradingAccessory
state={{
grading: neutralGrading(),
compareEnabled: false,
runtimeStatus: { state: "pending", message: "Waiting for shader" },
commitCompare: vi.fn(),
resetGrading: vi.fn(),
}}
/>,
);
const messageEl = host.querySelector('[data-flat-grade-status-message="true"]');
expect(messageEl).not.toBeNull();
expect(messageEl?.textContent).toBe("Waiting for shader");
expect(host.textContent).toContain("Waiting for shader");
act(() => root.unmount());
});
function activeGrading() {
const grading = neutralGrading();
return { ...grading, adjust: { ...grading.adjust, contrast: 0.2 } };
}
it("activates hold-to-compare on pointerdown and releases on window pointerup", () => {
const commitCompare = vi.fn();
const { host, root } = renderInto(
<FlatColorGradingAccessory
state={{
grading: activeGrading(),
compareEnabled: false,
runtimeStatus: { state: "active", message: "Shader active" },
commitCompare,
resetGrading: vi.fn(),
}}
/>,
);
const compareButton = host.querySelector<HTMLButtonElement>(
'[aria-label="Hold to show original"]',
);
if (!compareButton) throw new Error("expected a compare button");
act(() => compareButton.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true })));
expect(commitCompare).toHaveBeenNthCalledWith(1, true);
act(() => window.dispatchEvent(new MouseEvent("pointerup", { bubbles: true })));
expect(commitCompare).toHaveBeenNthCalledWith(2, false);
act(() => root.unmount());
});
it("activates hold-to-compare via keyboard Space and releases on keyup", () => {
const commitCompare = vi.fn();
const { host, root } = renderInto(
<FlatColorGradingAccessory
state={{
grading: activeGrading(),
compareEnabled: false,
runtimeStatus: { state: "active", message: "Shader active" },
commitCompare,
resetGrading: vi.fn(),
}}
/>,
);
const compareButton = host.querySelector<HTMLButtonElement>(
'[aria-label="Hold to show original"]',
);
if (!compareButton) throw new Error("expected a compare button");
act(() =>
compareButton.dispatchEvent(
new KeyboardEvent("keydown", { key: " ", bubbles: true, cancelable: true }),
),
);
expect(commitCompare).toHaveBeenNthCalledWith(1, true);
act(() =>
compareButton.dispatchEvent(
new KeyboardEvent("keyup", { key: " ", bubbles: true, cancelable: true }),
),
);
expect(commitCompare).toHaveBeenNthCalledWith(2, false);
act(() => root.unmount());
});
it("releases an active hold when the window loses focus mid-hold", () => {
const commitCompare = vi.fn();
const { host, root } = renderInto(
<FlatColorGradingAccessory
state={{
grading: activeGrading(),
compareEnabled: false,
runtimeStatus: { state: "active", message: "Shader active" },
commitCompare,
resetGrading: vi.fn(),
}}
/>,
);
const compareButton = host.querySelector<HTMLButtonElement>(
'[aria-label="Hold to show original"]',
);
if (!compareButton) throw new Error("expected a compare button");
act(() => compareButton.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true })));
expect(commitCompare).toHaveBeenNthCalledWith(1, true);
act(() => window.dispatchEvent(new Event("blur")));
expect(commitCompare).toHaveBeenNthCalledWith(2, false);
act(() => root.unmount());
});
});
function neutralPropsBase() {
@@ -280,6 +384,43 @@ describe("FlatColorGradingSection — Adjust sliders", () => {
expect(onCommitColorGrading.mock.calls[0][0].adjust.saturation).toBe(0.2);
act(() => root.unmount());
});
it("revives a grade parked at 0% strength back to 100% when an Adjust slider is committed", () => {
const onCommitColorGrading = vi.fn();
const grading = { ...neutralGrading(), intensity: 0 };
const { host, root } = renderInto(
<FlatColorGradingSection
{...neutralPropsBase()}
grading={grading}
onCommitColorGrading={onCommitColorGrading}
/>,
);
const contrastRow = findRowByText(host, '[data-flat-grade-adjust="true"]', "Contrast");
// min=-100, max=100, step=1, ratio=0.75 -> raw=50 -> commit(50) -> adjust.contrast = 0.5
dragSliderTrack(contrastRow, 75, 100);
expect(onCommitColorGrading).toHaveBeenCalledTimes(1);
expect(onCommitColorGrading.mock.calls[0][0].intensity).toBe(1);
expect(onCommitColorGrading.mock.calls[0][0].adjust.contrast).toBe(0.5);
act(() => root.unmount());
});
it("does NOT force intensity to revive when the Strength slider itself is dragged — it writes the value directly", () => {
const onCommitColorGrading = vi.fn();
const grading = { ...neutralGrading(), intensity: 0 };
const { host, root } = renderInto(
<FlatColorGradingSection
{...neutralPropsBase()}
grading={grading}
onCommitColorGrading={onCommitColorGrading}
/>,
);
const strengthRow = findRowByText(host, "div", "Strength", "startsWith");
// min=0, max=100, step=1, ratio=0.4 -> raw=40 -> commit(40) -> intensity = 40/100 = 0.4
dragSliderTrack(strengthRow, 40, 100);
expect(onCommitColorGrading).toHaveBeenCalledTimes(1);
expect(onCommitColorGrading.mock.calls[0][0].intensity).toBe(0.4);
act(() => root.unmount());
});
});
describe("FlatColorGradingSection — Vignette and Grain", () => {
@@ -386,6 +527,46 @@ describe("FlatColorGradingSection — HDR banner and Apply scope", () => {
act(() => root.unmount());
});
it("shows a codec/profile/pixel-format/color detail line in the HDR banner when metadata provides it", () => {
const { host, root } = renderInto(
<FlatColorGradingSection
{...neutralPropsBase()}
mediaMetadata={{
kind: "video",
color: {
dynamicRange: "hdr",
hdrTransfer: "pq",
label: "HDR10",
isHdr: true,
codecName: "hevc",
profile: "Main10",
pixelFormat: "yuv420p10le",
colorPrimaries: "bt2020",
colorTransfer: "smpte2084",
},
}}
/>,
);
const detail = host.querySelector('[data-flat-grade-hdr-detail="true"]');
expect(detail).not.toBeNull();
expect(detail?.textContent).toBe("hevc · Main10 · yuv420p10le · bt2020 · smpte2084");
act(() => root.unmount());
});
it("omits the HDR detail line entirely when no detail fields are populated", () => {
const { host, root } = renderInto(
<FlatColorGradingSection
{...neutralPropsBase()}
mediaMetadata={{
kind: "video",
color: { dynamicRange: "hdr", hdrTransfer: "pq", label: "HDR10", isHdr: true },
}}
/>,
);
expect(host.querySelector('[data-flat-grade-hdr-detail="true"]')).toBeNull();
act(() => root.unmount());
});
it("omits the HDR banner for SDR media", () => {
const { host, root } = renderInto(
<FlatColorGradingSection
@@ -49,20 +49,43 @@ export function FlatColorGradingAccessory({
commitCompare(false);
window.removeEventListener("pointerup", release);
window.removeEventListener("pointercancel", release);
window.removeEventListener("blur", release);
};
window.addEventListener("pointerup", release);
window.addEventListener("pointercancel", release);
window.addEventListener("blur", release);
}}
onBlur={() => {
if (compareEnabled) commitCompare(false);
}}
onKeyDown={(e) => {
if (!gradingActive || (e.key !== " " && e.key !== "Enter")) return;
e.preventDefault();
if (!compareEnabled) commitCompare(true);
}}
onKeyUp={(e) => {
if (!gradingActive || (e.key !== " " && e.key !== "Enter")) return;
e.preventDefault();
commitCompare(false);
}}
title="Hold to show original"
className="flex-shrink-0 text-panel-text-3 hover:text-panel-text-1 disabled:cursor-not-allowed disabled:opacity-40"
>
<Compare size={12} />
</button>
<span
data-flat-grade-status-dot="true"
title={runtimeStatus.message}
className={`h-[5px] w-[5px] flex-shrink-0 rounded-full ${STATUS_DOT_CLASS[runtimeStatus.state]}`}
/>
<span className="flex min-w-0 items-center gap-1" title={runtimeStatus.message}>
<span
data-flat-grade-status-dot="true"
title={runtimeStatus.message}
className={`h-[5px] w-[5px] flex-shrink-0 rounded-full ${STATUS_DOT_CLASS[runtimeStatus.state]}`}
/>
<span
data-flat-grade-status-message="true"
className="max-w-[84px] truncate text-[9px] text-panel-text-4"
>
{runtimeStatus.message}
</span>
</span>
<button
type="button"
data-flat-grade-reset="true"
@@ -100,6 +123,11 @@ const ADJUST_SLIDERS: Array<{
{ key: "saturation", label: "Saturation", min: -100, max: 100, step: 1 },
];
function visibleIntensity(grading: NormalizedHfColorGrading): number {
// Earlier drafts could persist 0% strength; the next manual edit should revive visible grading.
return grading.intensity === 0 ? 1 : grading.intensity;
}
function formatAdjustValue(key: HfColorGradingAdjustKey, rawPercent: number): string {
if (key === "exposure") {
const stops = rawPercent / 100;
@@ -140,6 +168,15 @@ const EFFECT_SLIDERS: Array<{ key: HfColorGradingEffectKey; label: string }> = [
function HdrBanner({ metadata }: { metadata: MediaMetadata | null }) {
if (metadata?.color.dynamicRange !== "hdr") return null;
const details = [
metadata.color.codecName,
metadata.color.profile,
metadata.color.pixelFormat,
metadata.color.colorPrimaries,
metadata.color.colorTransfer,
]
.filter(Boolean)
.join(" · ");
return (
<div
data-flat-grade-hdr-banner="true"
@@ -155,6 +192,14 @@ function HdrBanner({ metadata }: { metadata: MediaMetadata | null }) {
These controls use the current SDR shader preview path. Render may stay HDR-tagged, but this
is not true HDR color grading yet.
</p>
{details && (
<p
data-flat-grade-hdr-detail="true"
className="mt-0.5 truncate text-[9px] text-amber-100/55"
>
{details}
</p>
)}
</div>
);
}
@@ -201,7 +246,11 @@ export function FlatColorGradingSection({
onCommitColorGrading({ ...grading, intensity: value / 100 });
};
const applyLut = (src: string | null, intensity = 1) => {
onCommitColorGrading({ ...grading, lut: src ? { src, intensity } : null });
onCommitColorGrading({
...grading,
intensity: visibleIntensity(grading),
lut: src ? { src, intensity } : null,
});
};
const importLuts = async (files: FileList | null) => {
if (!files?.length || !onImportAssets) return;
@@ -225,11 +274,16 @@ export function FlatColorGradingSection({
displayValue={`${Math.round(value * 100)}%`}
centerTick={key === "vignetteRoundness"}
onCommit={(next) =>
onCommitColorGrading({ ...grading, details: { ...grading.details, [key]: next / 100 } })
onCommitColorGrading({
...grading,
intensity: visibleIntensity(grading),
details: { ...grading.details, [key]: next / 100 },
})
}
onReset={() =>
onCommitColorGrading({
...grading,
intensity: visibleIntensity(grading),
details: { ...grading.details, [key]: spec.defaultValue },
})
}
@@ -361,12 +415,14 @@ export function FlatColorGradingSection({
onCommit={(next) =>
onCommitColorGrading({
...grading,
intensity: visibleIntensity(grading),
adjust: { ...grading.adjust, [slider.key]: next / 100 },
})
}
onReset={() =>
onCommitColorGrading({
...grading,
intensity: visibleIntensity(grading),
adjust: { ...grading.adjust, [slider.key]: 0 },
})
}
@@ -432,12 +488,14 @@ export function FlatColorGradingSection({
onCommit={(next) =>
onCommitColorGrading({
...grading,
intensity: visibleIntensity(grading),
effects: { ...grading.effects, [slider.key]: next / 100 },
})
}
onReset={() =>
onCommitColorGrading({
...grading,
intensity: visibleIntensity(grading),
effects: { ...grading.effects, [slider.key]: 0 },
})
}