From c1dde2898076b4d9b71b81d86cdf0f419350d93f Mon Sep 17 00:00:00 2001 From: ukimsanov Date: Sat, 25 Jul 2026 23:33:12 -0700 Subject: [PATCH] fix(cli): address media treatment review findings --- .../src/commands/media-treatment-analysis.ts | 4 +- .../cli/src/commands/media-treatment.test.ts | 56 ++- packages/cli/src/commands/media-treatment.ts | 34 +- packages/core/package.json | 10 + packages/core/src/mediaGradeAnalyzer.ts | 356 ++++++++++++++++++ .../mediaGradeAnalyzer.vendoredParity.test.ts | 70 ++++ skills-manifest.json | 4 +- skills/media-use/SKILL.md | 2 +- .../media-use/references/media-treatments.md | 2 +- .../scripts/lib/grade-analyzer.d.mts | 66 ---- .../media-use/scripts/lib/grade-analyzer.mjs | 46 ++- 11 files changed, 526 insertions(+), 124 deletions(-) create mode 100644 packages/core/src/mediaGradeAnalyzer.ts create mode 100644 packages/core/src/mediaGradeAnalyzer.vendoredParity.test.ts delete mode 100644 skills/media-use/scripts/lib/grade-analyzer.d.mts diff --git a/packages/cli/src/commands/media-treatment-analysis.ts b/packages/cli/src/commands/media-treatment-analysis.ts index e5bf45590..bde303fa2 100644 --- a/packages/cli/src/commands/media-treatment-analysis.ts +++ b/packages/cli/src/commands/media-treatment-analysis.ts @@ -1,11 +1,11 @@ import { analyzeMediaGrade, type MediaTreatmentAnalysis, -} from "../../../../skills/media-use/scripts/lib/grade-analyzer.mjs"; +} from "@hyperframes/core/media-grade-analyzer"; import { findFFmpeg, findFFprobe, getFFmpegInstallHint } from "../browser/ffmpeg.js"; interface CliMediaTreatmentAnalysis extends Omit { - suggestedPatch: { adjust: Record }; + suggestedPatch: { adjust: MediaTreatmentAnalysis["adjust"] }; } export function analyzeMediaTreatment(mediaPath: string): CliMediaTreatmentAnalysis { diff --git a/packages/cli/src/commands/media-treatment.test.ts b/packages/cli/src/commands/media-treatment.test.ts index f86d421fe..bbcecbaf5 100644 --- a/packages/cli/src/commands/media-treatment.test.ts +++ b/packages/cli/src/commands/media-treatment.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { runCommand } from "citty"; @@ -210,21 +210,47 @@ describe("applyMediaTreatmentToHtml", () => { }); it("resolves nested composition media through the shared project-root contract", () => { - const project = "/tmp/hf-project"; + const project = mkdtempSync(join(tmpdir(), "hf-media-treatment-assets-")); + mkdirSync(join(project, "capture"), { recursive: true }); + mkdirSync(join(project, "assets"), { recursive: true }); + writeFileSync(join(project, "capture", "talking-head.mp4"), ""); + writeFileSync(join(project, "assets", "photo.webp"), ""); + writeFileSync(join(project, "assets", "My Clip.mp4"), ""); - expect( - resolveMediaTreatmentSource( - project, - "compositions/scene.html", - "../capture/talking-head.mp4?v=1#frame", - ), - ).toBe(join(project, "capture/talking-head.mp4")); - expect( - resolveMediaTreatmentSource(project, "compositions/scene.html", "assets/photo.webp"), - ).toBe(join(project, "assets/photo.webp")); - expect(() => - resolveMediaTreatmentSource(project, "compositions/scene.html", "https://example.com/a.mp4"), - ).toThrow(/local project asset/); + try { + expect( + resolveMediaTreatmentSource( + project, + "compositions/scene.html", + "../capture/talking-head.mp4?v=1#frame", + ), + ).toBe(join(project, "capture/talking-head.mp4")); + expect( + resolveMediaTreatmentSource(project, "compositions/scene.html", "assets/photo.webp"), + ).toBe(join(project, "assets/photo.webp")); + expect( + resolveMediaTreatmentSource( + project, + "compositions/scene.html", + "/assets/My%20Clip.mp4?v=1", + ), + ).toBe(join(project, "assets/My Clip.mp4")); + expect(() => + resolveMediaTreatmentSource( + project, + "compositions/scene.html", + "https://example.com/a.mp4", + ), + ).toThrow(/local project asset/); + expect(() => resolveMediaTreatmentSource(project, "compositions/scene.html", "#")).toThrow( + /local project asset/, + ); + expect(() => + resolveMediaTreatmentSource(project, "compositions/scene.html", "missing.mp4"), + ).toThrow(/Media file not found/); + } finally { + rmSync(project, { recursive: true, force: true }); + } }); it("merges a validated patch and reports the stored before and after payloads", () => { diff --git a/packages/cli/src/commands/media-treatment.ts b/packages/cli/src/commands/media-treatment.ts index 2f6576000..bdeba5889 100644 --- a/packages/cli/src/commands/media-treatment.ts +++ b/packages/cli/src/commands/media-treatment.ts @@ -12,6 +12,11 @@ import { isColorGradingVariableRef, validateColorGradingContract, } from "@hyperframes/parsers/color-grading-contract"; +import { + cleanAssetUrl, + isRemoteOrInlineUrl, + resolveExistingLocalAsset, +} from "@hyperframes/parsers/asset-resolution"; import { rewriteAssetPath } from "@hyperframes/parsers/asset-paths"; import { patchElementInHtml } from "@hyperframes/studio-server/source-mutation"; import { defineCommand } from "citty"; @@ -247,7 +252,7 @@ export function getMediaTreatmentCapabilityDetail(id: string): unknown { description: "Deterministic source measurements for agent decisions; visual scopes remain a Studio display.", command: - "hyperframes media-treatment --file --selector --analyze --json", + "hyperframes media-treatment --file compositions/scene.html --selector '#hero' --analyze --json", output: [ "source color metadata and HDR/LOG warnings", "luma percentile and clipping evidence", @@ -481,34 +486,26 @@ function mediaSourceForElement(element: Element): string { return src; } -function stripUrlSuffix(value: string): string { - const queryIndex = value.indexOf("?"); - const hashIndex = value.indexOf("#"); - if (queryIndex < 0 && hashIndex < 0) return value; - const end = - queryIndex < 0 ? hashIndex : hashIndex < 0 ? queryIndex : Math.min(queryIndex, hashIndex); - return value.slice(0, end); -} - export function resolveMediaTreatmentSource( projectDir: string, compositionFile: string, source: string, ): string { - const cleanSource = stripUrlSuffix(source.trim()); - if (/^[a-z][a-z\d+.-]*:/i.test(cleanSource) || cleanSource.startsWith("//")) { + const sourceUrl = source.trim(); + if (!sourceUrl) throw new Error("Selected media has no analyzable local src"); + if (isRemoteOrInlineUrl(sourceUrl)) { throw new Error( "Media analysis requires a local project asset; freeze remote media with media-use first", ); } + const cleanSource = cleanAssetUrl(sourceUrl); + if (!cleanSource) throw new Error("Selected media has no analyzable local src"); const projectRelative = cleanSource.startsWith("/") - ? cleanSource.slice(1) + ? cleanSource : rewriteAssetPath(compositionFile, cleanSource); - const mediaPath = resolve(projectDir, projectRelative); - if (!isPathInside(mediaPath, projectDir)) { - throw new Error("Selected media resolves outside the project"); - } - return mediaPath; + const asset = resolveExistingLocalAsset(projectDir, projectRelative); + if (!asset) throw new Error(`Media file not found: ${source}`); + return asset.resolved; } function parseGrading(raw: string | undefined, apply: boolean, clear: boolean): unknown { @@ -604,7 +601,6 @@ function analyzeTarget(args: MediaTreatmentCommandArgs) { const source = mediaSourceForElement(element); const compositionFile = relative(project.dir, filePath).split("\\").join("/"); const mediaPath = resolveMediaTreatmentSource(project.dir, compositionFile, source); - if (!existsSync(mediaPath)) throw new Error(`Media file not found: ${source}`); return { ok: true, action: "analyze", diff --git a/packages/core/package.json b/packages/core/package.json index e0d3f428f..9df53a5c0 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -100,6 +100,12 @@ "import": "./src/colorLuts.ts", "types": "./src/colorLuts.ts" }, + "./media-grade-analyzer": { + "bun": "./src/mediaGradeAnalyzer.ts", + "node": "./dist/mediaGradeAnalyzer.js", + "import": "./src/mediaGradeAnalyzer.ts", + "types": "./src/mediaGradeAnalyzer.ts" + }, "./storyboard": { "bun": "./src/storyboard/index.ts", "node": "./dist/storyboard/index.js", @@ -334,6 +340,10 @@ "import": "./dist/colorLuts.js", "types": "./dist/colorLuts.d.ts" }, + "./media-grade-analyzer": { + "import": "./dist/mediaGradeAnalyzer.js", + "types": "./dist/mediaGradeAnalyzer.d.ts" + }, "./storyboard": { "import": "./dist/storyboard/index.js", "types": "./dist/storyboard/index.d.ts" diff --git a/packages/core/src/mediaGradeAnalyzer.ts b/packages/core/src/mediaGradeAnalyzer.ts new file mode 100644 index 000000000..7ea693435 --- /dev/null +++ b/packages/core/src/mediaGradeAnalyzer.ts @@ -0,0 +1,356 @@ +import { execFileSync } from "node:child_process"; +import { basename, extname } from "node:path"; + +const IMAGE_EXTENSIONS = new Set([ + ".jpg", + ".jpeg", + ".png", + ".webp", + ".gif", + ".bmp", + ".tif", + ".tiff", +]); +const SAMPLE_FRAMES = 5; +const DEFAULT_TIMEOUT_MS = 15_000; + +export interface GradeSignalFrame { + [key: string]: number | undefined; + ptsTime?: number; + YMIN?: number; + YLOW?: number; + YAVG?: number; + YHIGH?: number; + YMAX?: number; + UAVG?: number; + VAVG?: number; + SATAVG?: number; +} + +export interface GradeMediaProbe { + duration: number | null; + colorSpace: string; + transfer: string; + primaries: string; + pixelFormat: string; +} + +export interface MediaGradeAdjust { + exposure: number; + contrast: number; + whites: number; + blacks: number; + temperature: number; + tint: number; +} + +export interface MediaTreatmentMeasurements { + frames: number; + yMin: number; + yLow: number; + yAvg: number; + yHigh: number; + yMax: number; + uAvg: number; + vAvg: number; + satAvg: number; + shadowClipRisk: number; + highlightClipRisk: number; +} + +export interface MediaTreatmentAnalysis { + adjust: MediaGradeAdjust; + measured: MediaTreatmentMeasurements; + source: { + colorSpace: string; + transfer: string; + primaries: string; + pixelFormat: string; + hdr: boolean; + log: "unknown"; + }; + diagnosis: string[]; + warnings: string[]; +} + +type AdjustKey = keyof MediaGradeAdjust; +type NumericStats = Readonly>; + +const ADJUST_LIMITS: Record = { + exposure: { min: -2, max: 2 }, + contrast: { min: -1, max: 1 }, + whites: { min: -1, max: 1 }, + blacks: { min: -1, max: 1 }, + temperature: { min: -1, max: 1 }, + tint: { min: -1, max: 1 }, +}; + +function clamp(value: number, key: AdjustKey): number { + const limit = ADJUST_LIMITS[key]; + if (!Number.isFinite(value)) return 0; + return Math.min(limit.max, Math.max(limit.min, value)); +} + +function round(value: number): number { + const rounded = Math.round(value * 1000) / 1000; + return Object.is(rounded, -0) ? 0 : rounded; +} + +function average(values: number[]): number { + return values.reduce((sum, value) => sum + value, 0) / Math.max(1, values.length); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function asRecord(value: unknown): Record { + return isRecord(value) ? value : {}; +} + +function probeMedia(mediaPath: string, ffprobePath: string): GradeMediaProbe { + try { + const raw = execFileSync( + ffprobePath, + [ + "-v", + "error", + "-select_streams", + "v:0", + "-show_entries", + "stream=color_space,color_transfer,color_primaries,pix_fmt,duration:format=duration", + "-of", + "json", + mediaPath, + ], + { encoding: "utf8", timeout: 5_000, stdio: ["ignore", "pipe", "pipe"] }, + ); + const parsed = asRecord(JSON.parse(raw)); + const streams = Array.isArray(parsed.streams) ? parsed.streams : []; + const stream = asRecord(streams[0]); + const format = asRecord(parsed.format); + const duration = Number(stream.duration ?? format.duration); + const text = (key: string) => { + const value = stream[key]; + return typeof value === "string" && value ? value : "unknown"; + }; + return { + duration: Number.isFinite(duration) && duration > 0 ? duration : null, + colorSpace: text("color_space"), + transfer: text("color_transfer"), + primaries: text("color_primaries"), + pixelFormat: text("pix_fmt"), + }; + } catch { + return { + duration: null, + colorSpace: "unknown", + transfer: "unknown", + primaries: "unknown", + pixelFormat: "unknown", + }; + } +} + +export function parseMediaTreatmentSignalStats(raw: string): GradeSignalFrame[] { + const frames: GradeSignalFrame[] = []; + let current: GradeSignalFrame | null = null; + for (const line of raw.split(/\r?\n/)) { + const frame = line.match(/^frame:\d+.*pts_time:([+-]?(?:\d+(?:\.\d+)?|\.\d+))/); + if (frame?.[1]) { + if (current) frames.push(current); + current = { ptsTime: Number(frame[1]) }; + continue; + } + const stat = line.match(/lavfi\.signalstats\.([A-Z]+)=([+-]?(?:\d+(?:\.\d+)?|\.\d+))/); + if (!stat?.[1] || !stat[2]) continue; + current ??= {}; + current[stat[1]] = Number(stat[2]); + } + if (current) frames.push(current); + return frames.filter( + (frame) => + Number.isFinite(frame.YMIN) && + Number.isFinite(frame.YLOW) && + Number.isFinite(frame.YAVG) && + Number.isFinite(frame.YHIGH) && + Number.isFinite(frame.YMAX) && + Number.isFinite(frame.UAVG) && + Number.isFinite(frame.VAVG), + ); +} + +function summarizeFrames(frames: readonly GradeSignalFrame[]): NumericStats { + if (frames.length === 0) throw new Error("FFmpeg returned no analyzable video frames"); + const values = (key: string) => frames.map((frame) => Number(frame[key])); + return { + frames: frames.length, + yMin: Math.min(...values("YMIN")), + yLow: average(values("YLOW")), + yAvg: average(values("YAVG")), + yHigh: average(values("YHIGH")), + yMax: Math.max(...values("YMAX")), + uAvg: average(values("UAVG")), + vAvg: average(values("VAVG")), + satAvg: average(frames.map((frame) => frame.SATAVG ?? 0)), + shadowClipRisk: average(frames.map((frame) => (Number(frame.YLOW) <= 16 ? 1 : 0))), + highlightClipRisk: average(frames.map((frame) => (Number(frame.YHIGH) >= 235 ? 1 : 0))), + }; +} + +function suggestedExposure(normalizedAverage: number, yLow: number, yHigh: number): number { + if (normalizedAverage < 0.28 && yHigh / 255 < 0.65) { + return clamp((0.32 - normalizedAverage) * 1.2, "exposure"); + } + if (normalizedAverage > 0.72 && yLow / 255 > 0.3) { + return clamp((0.68 - normalizedAverage) * 1.2, "exposure"); + } + return 0; +} + +function suggestedTemperature(uAverage: number, vAverage: number): number { + const chromaWarmth = (vAverage - 128 + (128 - uAverage)) / 128; + return Math.abs(chromaWarmth) >= 0.08 ? clamp(-chromaWarmth * 0.25, "temperature") : 0; +} + +function suggestedTint(uAverage: number, vAverage: number): number { + const cast = uAverage + vAverage - 256; + return Math.abs(cast) >= 10 ? clamp(-cast / 512, "tint") : 0; +} + +export function statsToAdjust( + stats: NumericStats, +): Pick { + const yMin = Number(stats.yMin); + const yLow = Number(stats.yLow ?? stats.yMin); + const yMax = Number(stats.yMax); + const yHigh = Number(stats.yHigh ?? stats.yMax); + const yAvg = Number(stats.yAvg); + const uAvg = Number(stats.uAvg); + const vAvg = Number(stats.vAvg); + const shadowClipRisk = Number(stats.shadowClipRisk ?? (yLow <= 16 ? 1 : 0)); + const highlightClipRisk = Number(stats.highlightClipRisk ?? (yHigh >= 235 ? 1 : 0)); + const percentileSpread = (yHigh - yLow) / 255; + const normalizedAverage = yAvg / 255; + const contrast = percentileSpread < 0.35 ? clamp((0.35 - percentileSpread) * 0.4, "contrast") : 0; + + return { + adjust: { + exposure: round(suggestedExposure(normalizedAverage, yLow, yHigh)), + contrast: round(contrast), + blacks: round(clamp(shadowClipRisk * 0.08, "blacks")), + whites: round(clamp(-highlightClipRisk * 0.08, "whites")), + temperature: round(suggestedTemperature(uAvg, vAvg)), + tint: round(suggestedTint(uAvg, vAvg)), + }, + measured: { + frames: Number(stats.frames ?? 1), + yMin: round(yMin), + yLow: round(yLow), + yAvg: round(yAvg), + yHigh: round(yHigh), + yMax: round(yMax), + uAvg: round(uAvg), + vAvg: round(vAvg), + satAvg: round(Number(stats.satAvg ?? 0)), + shadowClipRisk: round(shadowClipRisk), + highlightClipRisk: round(highlightClipRisk), + }, + }; +} + +export function summarizeMediaTreatmentAnalysis( + probe: GradeMediaProbe, + frames: readonly GradeSignalFrame[], +): MediaTreatmentAnalysis { + const result = statsToAdjust(summarizeFrames(frames)); + const hdr = ["smpte2084", "arib-std-b67"].includes(probe.transfer); + const diagnosis: string[] = []; + if (result.measured.shadowClipRisk > 0) { + diagnosis.push("sampled frames contain deep or clipped shadows"); + } + if (result.measured.highlightClipRisk > 0) { + diagnosis.push("sampled frames contain bright or clipped highlights"); + } + if (diagnosis.length === 0) diagnosis.push("no obvious technical imbalance in sampled frames"); + + const warnings: string[] = []; + if (hdr) warnings.push("HDR transfer detected; the realtime treatment path is SDR/Rec.709."); + if (probe.colorSpace === "unknown" || probe.transfer === "unknown") { + warnings.push( + "Source color metadata is incomplete; camera LOG cannot be identified reliably from container metadata alone.", + ); + } + + return { + ...result, + source: { + colorSpace: probe.colorSpace, + transfer: probe.transfer, + primaries: probe.primaries, + pixelFormat: probe.pixelFormat, + hdr, + log: "unknown", + }, + diagnosis, + warnings, + }; +} + +export function analyzeMediaGrade( + mediaPath: string, + options: { ffmpegPath?: string; ffprobePath?: string } = {}, +): MediaTreatmentAnalysis { + const ffmpegPath = options.ffmpegPath ?? "ffmpeg"; + const ffprobePath = options.ffprobePath ?? "ffprobe"; + try { + const probe = probeMedia(mediaPath, ffprobePath); + const isImage = IMAGE_EXTENSIONS.has(extname(mediaPath).toLowerCase()); + const fps = + !isImage && probe.duration + ? Math.max(0.1, Math.min(2, SAMPLE_FRAMES / probe.duration)) + : null; + const filters = [ + fps ? `fps=${fps.toFixed(4)}` : null, + "format=yuv444p", + "signalstats", + "metadata=print:file=-", + ] + .filter(Boolean) + .join(","); + const raw = execFileSync( + ffmpegPath, + [ + "-hide_banner", + "-nostdin", + "-v", + "error", + "-i", + mediaPath, + "-vf", + filters, + "-frames:v", + String(SAMPLE_FRAMES), + "-f", + "null", + "-", + ], + { + encoding: "utf8", + timeout: Number(process.env.HYPERFRAMES_ANALYZE_TIMEOUT_MS) || DEFAULT_TIMEOUT_MS, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + return summarizeMediaTreatmentAnalysis(probe, parseMediaTreatmentSignalStats(raw)); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`grade analysis failed for ${mediaPath}: ${message}`); + } +} + +export function formatMeasuredNote( + mediaPath: string, + measured: MediaTreatmentMeasurements, +): string { + return `media-use: measured ${basename(mediaPath)}: frames=${measured.frames}, YMIN=${measured.yMin}, YLOW=${measured.yLow}, YAVG=${measured.yAvg}, YHIGH=${measured.yHigh}, YMAX=${measured.yMax}, UAVG=${measured.uAvg}, VAVG=${measured.vAvg}; adjust is a starting suggestion`; +} diff --git a/packages/core/src/mediaGradeAnalyzer.vendoredParity.test.ts b/packages/core/src/mediaGradeAnalyzer.vendoredParity.test.ts new file mode 100644 index 000000000..b5d186129 --- /dev/null +++ b/packages/core/src/mediaGradeAnalyzer.vendoredParity.test.ts @@ -0,0 +1,70 @@ +// @vitest-environment node + +import { describe, expect, it } from "vitest"; +import { + formatMeasuredNote, + parseMediaTreatmentSignalStats, + statsToAdjust, + summarizeMediaTreatmentAnalysis, + type GradeMediaProbe, +} from "./mediaGradeAnalyzer"; + +const SIGNALSTATS = `frame:0 pts:0 pts_time:0 +lavfi.signalstats.YMIN=4 +lavfi.signalstats.YLOW=8 +lavfi.signalstats.YAVG=100 +lavfi.signalstats.YHIGH=240 +lavfi.signalstats.YMAX=250 +lavfi.signalstats.UAVG=120 +lavfi.signalstats.VAVG=140 +lavfi.signalstats.SATAVG=40 +frame:1 pts:1 pts_time:1 +lavfi.signalstats.YMIN=20 +lavfi.signalstats.YLOW=20 +lavfi.signalstats.YAVG=120 +lavfi.signalstats.YHIGH=220 +lavfi.signalstats.YMAX=230 +lavfi.signalstats.UAVG=125 +lavfi.signalstats.VAVG=135 +lavfi.signalstats.SATAVG=60`; + +const PROBE: GradeMediaProbe = { + duration: 4, + colorSpace: "bt2020nc", + transfer: "smpte2084", + primaries: "bt2020", + pixelFormat: "yuv420p10le", +}; + +function productResult() { + const frames = parseMediaTreatmentSignalStats(SIGNALSTATS); + const summary = summarizeMediaTreatmentAnalysis(PROBE, frames); + return { + frames, + summary, + adjust: statsToAdjust(summary.measured), + note: formatMeasuredNote("/tmp/frame.png", summary.measured), + }; +} + +async function vendoredResult() { + const moduleUrl = new URL( + "../../../skills/media-use/scripts/lib/grade-analyzer.mjs", + import.meta.url, + ); + const analyzer = await import(moduleUrl.href); + const frames = analyzer.parseMediaTreatmentSignalStats(SIGNALSTATS); + const summary = analyzer.summarizeMediaTreatmentAnalysis(PROBE, frames); + return { + frames, + summary, + adjust: analyzer.statsToAdjust(summary.measured), + note: analyzer.formatMeasuredNote("/tmp/frame.png", summary.measured), + }; +} + +describe("vendored media grade analyzer parity", () => { + it("keeps the standalone skill copy aligned with the typed product module", async () => { + expect(await vendoredResult()).toEqual(productResult()); + }); +}); diff --git a/skills-manifest.json b/skills-manifest.json index a43794cfd..4cb98f52e 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -46,8 +46,8 @@ "files": 10 }, "media-use": { - "hash": "f59de684c2733e7b", - "files": 152 + "hash": "68500ab488f7a5ef", + "files": 151 }, "motion-graphics": { "hash": "da65c1864debfe11", diff --git a/skills/media-use/SKILL.md b/skills/media-use/SKILL.md index 950e67545..5655ccb43 100644 --- a/skills/media-use/SKILL.md +++ b/skills/media-use/SKILL.md @@ -76,7 +76,7 @@ Surface an opportunity only when a concrete signal is present: | Image that is a placeholder, tiny, or upscaled-looking | a better `image` (and/or upscale — see `references/operations.md`) | | Hard scene cuts / transitions with no sound | transition `sfx` | | A piece over ~10s with no music bed | `bgm` | -| Footage that reads under/over-exposed or color-cast | a corrective grade (inspect the selected element with `hyperframes media-treatment --analyze`) | +| Footage that reads under/over-exposed or color-cast | a corrective grade (inspect it with `hyperframes media-treatment --selector '#hero' --analyze --json`) | | Photographic media that feels visually flat or off-topic | one specific source-appropriate preset or custom treatment, with the intended target named | | A meaningful media entrance/reveal that feels static | one supported seek-safe treatment animation; preserve color unless the request also justifies a preset | diff --git a/skills/media-use/references/media-treatments.md b/skills/media-use/references/media-treatments.md index 093fd37ff..6651b2804 100644 --- a/skills/media-use/references/media-treatments.md +++ b/skills/media-use/references/media-treatments.md @@ -152,7 +152,7 @@ drops frames. Do not impose or claim a universal hard cap from one machine. contact sheet justifies it. Do not invent keys, exceed reported ranges, or stack effects without a visual reason. Do not run the generic grade/LUT resolver first; it adds irrelevant candidates and may download an unused - LUT. Use `media-treatment --selector "" --analyze --json` only when + LUT. Use `media-treatment --selector "#hero" --analyze --json` only when correction needs measured signal evidence. 4. Persist pixel settings with `hyperframes media-treatment`; it validates and merges a patch into the existing nested `data-color-grading` contract. Use registered diff --git a/skills/media-use/scripts/lib/grade-analyzer.d.mts b/skills/media-use/scripts/lib/grade-analyzer.d.mts deleted file mode 100644 index 615ed0fd1..000000000 --- a/skills/media-use/scripts/lib/grade-analyzer.d.mts +++ /dev/null @@ -1,66 +0,0 @@ -export interface GradeSignalFrame { - ptsTime?: number; - YMIN?: number; - YLOW?: number; - YAVG?: number; - YHIGH?: number; - YMAX?: number; - UAVG?: number; - VAVG?: number; - SATAVG?: number; -} - -export interface GradeMediaProbe { - duration: number | null; - colorSpace: string; - transfer: string; - primaries: string; - pixelFormat: string; -} - -export interface MediaTreatmentAnalysis { - adjust: Record; - measured: { - frames: number; - yMin: number; - yLow: number; - yAvg: number; - yHigh: number; - yMax: number; - uAvg: number; - vAvg: number; - satAvg: number; - shadowClipRisk: number; - highlightClipRisk: number; - }; - source: { - colorSpace: string; - transfer: string; - primaries: string; - pixelFormat: string; - hdr: boolean; - log: "unknown"; - }; - diagnosis: string[]; - warnings: string[]; -} - -export function parseMediaTreatmentSignalStats(raw: string): GradeSignalFrame[]; -export function statsToAdjust( - stats: Record, -): Pick; -export function summarizeMediaTreatmentAnalysis( - probe: GradeMediaProbe, - frames: readonly GradeSignalFrame[], -): MediaTreatmentAnalysis; -export function analyzeMediaGrade( - mediaPath: string, - options?: { - ffmpegPath?: string; - ffprobePath?: string; - }, -): MediaTreatmentAnalysis; -export function formatMeasuredNote( - mediaPath: string, - measured: MediaTreatmentAnalysis["measured"], -): string; diff --git a/skills/media-use/scripts/lib/grade-analyzer.mjs b/skills/media-use/scripts/lib/grade-analyzer.mjs index f4ab8f47b..b673a65a5 100644 --- a/skills/media-use/scripts/lib/grade-analyzer.mjs +++ b/skills/media-use/scripts/lib/grade-analyzer.mjs @@ -1,3 +1,5 @@ +// Vendored plain-JS copy of packages/core/src/mediaGradeAnalyzer.ts. +// packages/core/src/mediaGradeAnalyzer.vendoredParity.test.ts guards behavior drift. import { execFileSync } from "node:child_process"; import { basename, extname } from "node:path"; @@ -114,6 +116,26 @@ function summarizeFrames(frames) { }; } +function suggestedExposure(normalizedAverage, yLow, yHigh) { + if (normalizedAverage < 0.28 && yHigh / 255 < 0.65) { + return clamp((0.32 - normalizedAverage) * 1.2, "exposure"); + } + if (normalizedAverage > 0.72 && yLow / 255 > 0.3) { + return clamp((0.68 - normalizedAverage) * 1.2, "exposure"); + } + return 0; +} + +function suggestedTemperature(uAverage, vAverage) { + const chromaWarmth = (vAverage - 128 + (128 - uAverage)) / 128; + return Math.abs(chromaWarmth) >= 0.08 ? clamp(-chromaWarmth * 0.25, "temperature") : 0; +} + +function suggestedTint(uAverage, vAverage) { + const cast = uAverage + vAverage - 256; + return Math.abs(cast) >= 10 ? clamp(-cast / 512, "tint") : 0; +} + export function statsToAdjust(stats) { const yMin = Number(stats.yMin); const yLow = Number(stats.yLow ?? stats.yMin); @@ -125,29 +147,17 @@ export function statsToAdjust(stats) { const shadowClipRisk = Number(stats.shadowClipRisk ?? (yLow <= 16 ? 1 : 0)); const highlightClipRisk = Number(stats.highlightClipRisk ?? (yHigh >= 235 ? 1 : 0)); const percentileSpread = (yHigh - yLow) / 255; - const normalizedAvg = yAvg / 255; - const exposure = - normalizedAvg < 0.28 && yHigh / 255 < 0.65 - ? clamp((0.32 - normalizedAvg) * 1.2, "exposure") - : normalizedAvg > 0.72 && yLow / 255 > 0.3 - ? clamp((0.68 - normalizedAvg) * 1.2, "exposure") - : 0; + const normalizedAverage = yAvg / 255; const contrast = percentileSpread < 0.35 ? clamp((0.35 - percentileSpread) * 0.4, "contrast") : 0; - const whites = clamp(-highlightClipRisk * 0.08, "whites"); - const blacks = clamp(shadowClipRisk * 0.08, "blacks"); - const chromaWarmth = (vAvg - 128 + (128 - uAvg)) / 128; - const temperature = - Math.abs(chromaWarmth) >= 0.08 ? clamp(-chromaWarmth * 0.25, "temperature") : 0; - const tint = Math.abs(uAvg + vAvg - 256) >= 10 ? clamp(-(uAvg + vAvg - 256) / 512, "tint") : 0; return { adjust: { - exposure: round(exposure), + exposure: round(suggestedExposure(normalizedAverage, yLow, yHigh)), contrast: round(contrast), - blacks: round(blacks), - whites: round(whites), - temperature: round(temperature), - tint: round(tint), + blacks: round(clamp(shadowClipRisk * 0.08, "blacks")), + whites: round(clamp(-highlightClipRisk * 0.08, "whites")), + temperature: round(suggestedTemperature(uAvg, vAvg)), + tint: round(suggestedTint(uAvg, vAvg)), }, measured: { frames: Number(stats.frames ?? 1),