Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .fallowrc.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,16 @@
"file": "packages/studio/src/components/editor/propertyPanelSections.tsx",
"exports": ["TextAreaField"],
},
// Link: its only consumer was FlatRadiusRow's uniform-only fallback row in
// propertyPanelFlatStyleSections.tsx, deleted by the Style parity fix
// (p8-task-style-parity) — that row was unreachable from a uniform radius
// and is now replaced by BorderRadiusEditor's own unlink toggle. Kept in
// the icon set for future reuse rather than deleted from a file outside
// this fix's scope.
{
"file": "packages/studio/src/icons/SystemIcons.tsx",
"exports": ["Link"],
},
],
"ignoreDependencies": [
// Runtime/dynamic deps not visible to static analysis: tsup `external`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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"
Expand All @@ -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>
);
}
Expand Down Expand Up @@ -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;
Expand All @@ -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 },
})
}
Expand Down Expand Up @@ -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 },
})
}
Expand Down Expand Up @@ -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 },
})
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,18 @@
// Mirrors legacy `propertyPanelStyleSections.tsx`'s `SelectField` "Style" options —
// the single source of truth for which border-style tokens are valid.
export const STROKE_STYLE_OPTIONS: string[] = [
"none",
"solid",
"dashed",
"dotted",
"double",
"hidden",
"groove",
"ridge",
"inset",
"outset",
];

export function formatStrokeSummary(widthPx: number, style: string): string {
return `${widthPx}px ${style}`;
}
Expand Down
Loading
Loading