@hyperframes/core 0.8.212 → 0.8.214
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/audioAnalysis.d.ts +3 -1
- package/dist/audioAnalysis.js +11 -3
- package/dist/audioPitch.d.ts +12 -0
- package/dist/audioPitch.js +91 -0
- package/dist/colorGradingBake.d.ts +14 -0
- package/dist/colorGradingBake.js +299 -0
- package/dist/colorGroups.d.ts +33 -0
- package/dist/colorGroups.js +59 -0
- package/dist/compiler/htmlBundler.js +5 -1
- package/dist/compiler/staticGuard.d.ts +4 -1
- package/dist/compiler/staticGuard.js +5 -3
- package/dist/fonts/deterministicFonts.d.ts +1 -1
- package/dist/fonts/deterministicFonts.js +18 -4
- package/dist/generated/position-edits-render-inline.js +1 -1
- package/dist/generated/runtime-inline.js +1 -1
- package/dist/hyperframe.runtime.iife.js +64 -64
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/runtime/domRealm.d.ts +2 -0
- package/dist/runtime/domRealm.js +4 -0
- package/dist/runtime/protocol.d.ts +13 -0
- package/dist/runtime/protocol.js +20 -0
- package/package.json +4 -4
package/dist/audioAnalysis.d.ts
CHANGED
|
@@ -99,6 +99,7 @@ export interface KindFeatures {
|
|
|
99
99
|
syllables: number;
|
|
100
100
|
/** Share of 50 ms frames within 20 dB of the loud end (95th percentile), 0–1: high for beds and speech, low for hits. */
|
|
101
101
|
sustain: number;
|
|
102
|
+
voiced?: number;
|
|
102
103
|
}
|
|
103
104
|
export declare const signalDuration: (signal: AudioSignal) => number;
|
|
104
105
|
/** Per-sample energy. Channels are squared first, so a reversed channel stays audible. */
|
|
@@ -112,10 +113,11 @@ export declare function silence(signal: AudioSignal, options?: SilenceOptions):
|
|
|
112
113
|
export declare function kindFeatures(signal: AudioSignal): KindFeatures;
|
|
113
114
|
/**
|
|
114
115
|
* A first guess at what the audio is. Length decides most of it: beds run long, effects short. Long audio is voice
|
|
115
|
-
* when its envelope moves at syllable rate without a beat; short audio
|
|
116
|
+
* when its envelope moves at syllable rate without a beat; short audio also holds its level. Both need a voice pitch.
|
|
116
117
|
*/
|
|
117
118
|
export declare function classify(signal: AudioSignal): KindGuess;
|
|
118
119
|
export * from "./audioAnalysisLadder.js";
|
|
120
|
+
export * from "./audioPitch.js";
|
|
119
121
|
export * from "./audioRhythm.js";
|
|
120
122
|
export * from "./audioSections.js";
|
|
121
123
|
export * from "./audioSnap.js";
|
package/dist/audioAnalysis.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { finish } from "./audioSteps.js";
|
|
6
6
|
import { fft } from "./fft.js";
|
|
7
|
+
import { voicedShare } from "./audioPitch.js";
|
|
7
8
|
import { rhythm } from "./audioRhythm.js";
|
|
8
9
|
import { sections } from "./audioSections.js";
|
|
9
10
|
const DB_FLOOR = -120;
|
|
@@ -416,26 +417,33 @@ export function kindFeatures(signal) {
|
|
|
416
417
|
sustain: round(sustainShare(signal)),
|
|
417
418
|
};
|
|
418
419
|
}
|
|
420
|
+
const VOICED_SPEECH = 0.15;
|
|
421
|
+
const VOICED_STEADY_SPEECH = 0.25;
|
|
419
422
|
/**
|
|
420
423
|
* A first guess at what the audio is. Length decides most of it: beds run long, effects short. Long audio is voice
|
|
421
|
-
* when its envelope moves at syllable rate without a beat; short audio
|
|
424
|
+
* when its envelope moves at syllable rate without a beat; short audio also holds its level. Both need a voice pitch.
|
|
422
425
|
*/
|
|
423
426
|
export function classify(signal) {
|
|
424
427
|
const features = kindFeatures(signal);
|
|
425
428
|
const { duration, regularity, syllables, sustain } = features;
|
|
426
429
|
const speechLike = syllables >= 0.35 && regularity < 0.35;
|
|
430
|
+
const voiced = () => (features.voiced ??= round(voicedShare(signal.channels, signal.sampleRate)));
|
|
431
|
+
const pitched = () => voiced() >= VOICED_SPEECH;
|
|
427
432
|
if (duration >= 8) {
|
|
428
433
|
if (sustain < 0.4)
|
|
429
434
|
return { value: "sfx", confidence: 0.6, features };
|
|
430
|
-
if (speechLike)
|
|
435
|
+
if (speechLike && pitched())
|
|
431
436
|
return { value: "voice", confidence: round(Math.min(0.9, 0.4 + syllables)), features };
|
|
437
|
+
if (regularity < 0.2 && syllables >= 0.17 && voiced() >= VOICED_STEADY_SPEECH)
|
|
438
|
+
return { value: "voice", confidence: 0.6, features };
|
|
432
439
|
return { value: "music", confidence: round(Math.min(0.9, 0.5 + regularity)), features };
|
|
433
440
|
}
|
|
434
|
-
if (duration >= 1.5 && syllables >= 0.17 && regularity < 0.3 && sustain >= 0.7)
|
|
441
|
+
if (duration >= 1.5 && syllables >= 0.17 && regularity < 0.3 && sustain >= 0.7 && pitched())
|
|
435
442
|
return { value: "voice", confidence: speechLike ? 0.8 : 0.6, features };
|
|
436
443
|
return { value: "sfx", confidence: duration <= 2 ? 0.85 : 0.65, features };
|
|
437
444
|
}
|
|
438
445
|
export * from "./audioAnalysisLadder.js";
|
|
446
|
+
export * from "./audioPitch.js";
|
|
439
447
|
export * from "./audioRhythm.js";
|
|
440
448
|
export * from "./audioSections.js";
|
|
441
449
|
export * from "./audioSnap.js";
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** The rate pitch is tracked at: 8 kHz keeps the 70–500 Hz search cheap. */
|
|
2
|
+
export declare const PITCH_RATE = 8000;
|
|
3
|
+
export interface PitchPoint {
|
|
4
|
+
t: number;
|
|
5
|
+
hz: number;
|
|
6
|
+
}
|
|
7
|
+
/** The fundamental of each voiced 40 ms frame of 8 kHz mono, by YIN's cumulative mean normalized difference. */
|
|
8
|
+
export declare function pitchTrack(pcm: Float32Array): PitchPoint[];
|
|
9
|
+
/** Mono at {@link PITCH_RATE}: channels averaged, a box filter as wide as the step against aliasing, then sampled. */
|
|
10
|
+
export declare function atPitchRate(channels: readonly Float32Array[], sampleRate: number): Float32Array;
|
|
11
|
+
/** The share of 40 ms frames with a fundamental in the voice range; hits, whooshes and noise have none. */
|
|
12
|
+
export declare function voicedShare(channels: readonly Float32Array[], sampleRate: number): number;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/** The rate pitch is tracked at: 8 kHz keeps the 70–500 Hz search cheap. */
|
|
2
|
+
export const PITCH_RATE = 8000;
|
|
3
|
+
const FRAME = 320;
|
|
4
|
+
const HOP = 320;
|
|
5
|
+
const LOW_HZ = 70;
|
|
6
|
+
const HIGH_HZ = 500;
|
|
7
|
+
const VOICED_DB = -35;
|
|
8
|
+
const PERIODIC = 0.15;
|
|
9
|
+
const MIN_LAG = Math.floor(PITCH_RATE / HIGH_HZ);
|
|
10
|
+
const MAX_LAG = Math.ceil(PITCH_RATE / LOW_HZ);
|
|
11
|
+
function voiced(channels, start) {
|
|
12
|
+
let energy = 0;
|
|
13
|
+
for (const pcm of channels)
|
|
14
|
+
for (let i = 0; i < FRAME; i++)
|
|
15
|
+
energy += pcm[start + i] ** 2;
|
|
16
|
+
return 10 * Math.log10(energy / (FRAME * channels.length) + 1e-12) >= VOICED_DB;
|
|
17
|
+
}
|
|
18
|
+
function difference(channels, start, tau) {
|
|
19
|
+
let sum = 0;
|
|
20
|
+
for (const pcm of channels) {
|
|
21
|
+
for (let i = 0; i < FRAME; i++) {
|
|
22
|
+
const delta = pcm[start + i] - pcm[start + i + tau];
|
|
23
|
+
sum += delta * delta;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return sum;
|
|
27
|
+
}
|
|
28
|
+
/** The first lag whose normalized difference dips under the threshold, followed down to its minimum; -1 if none. */
|
|
29
|
+
function periodOf(channels, start, d) {
|
|
30
|
+
let running = 0;
|
|
31
|
+
let lag = -1;
|
|
32
|
+
for (let tau = 1; tau <= MAX_LAG; tau++) {
|
|
33
|
+
const sum = difference(channels, start, tau);
|
|
34
|
+
running += sum;
|
|
35
|
+
d[tau] = running > 0 ? (sum * tau) / running : 1;
|
|
36
|
+
if (lag === -1) {
|
|
37
|
+
if (tau >= MIN_LAG && d[tau] < PERIODIC)
|
|
38
|
+
lag = tau;
|
|
39
|
+
}
|
|
40
|
+
else if (d[tau] > d[tau - 1])
|
|
41
|
+
break;
|
|
42
|
+
else if (d[tau] < d[lag])
|
|
43
|
+
lag = tau;
|
|
44
|
+
}
|
|
45
|
+
return lag;
|
|
46
|
+
}
|
|
47
|
+
/** The fundamental of each voiced 40 ms frame of 8 kHz mono, by YIN's cumulative mean normalized difference. */
|
|
48
|
+
export function pitchTrack(pcm) {
|
|
49
|
+
return channelPitchTrack([pcm]);
|
|
50
|
+
}
|
|
51
|
+
function channelPitchTrack(channels) {
|
|
52
|
+
const length = channels[0]?.length ?? 0;
|
|
53
|
+
const out = [];
|
|
54
|
+
const d = new Float64Array(MAX_LAG + 1);
|
|
55
|
+
for (let start = 0; start + FRAME + MAX_LAG <= length; start += HOP) {
|
|
56
|
+
if (!voiced(channels, start))
|
|
57
|
+
continue;
|
|
58
|
+
const lag = periodOf(channels, start, d);
|
|
59
|
+
if (lag > 0)
|
|
60
|
+
out.push({ t: (start + FRAME / 2) / PITCH_RATE, hz: PITCH_RATE / lag });
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
/** Mono at {@link PITCH_RATE}: channels averaged, a box filter as wide as the step against aliasing, then sampled. */
|
|
65
|
+
export function atPitchRate(channels, sampleRate) {
|
|
66
|
+
const length = channels[0]?.length ?? 0;
|
|
67
|
+
if (!length || !channels.length)
|
|
68
|
+
return new Float32Array(0);
|
|
69
|
+
const step = sampleRate / PITCH_RATE;
|
|
70
|
+
const width = Math.max(1, Math.round(step));
|
|
71
|
+
const sums = new Float64Array(length + 1);
|
|
72
|
+
for (let i = 0; i < length; i++) {
|
|
73
|
+
let mixed = 0;
|
|
74
|
+
for (const channel of channels)
|
|
75
|
+
mixed += channel[i];
|
|
76
|
+
sums[i + 1] = sums[i] + mixed / channels.length;
|
|
77
|
+
}
|
|
78
|
+
const out = new Float32Array(Math.floor(length / step));
|
|
79
|
+
for (let n = 0; n < out.length; n++) {
|
|
80
|
+
const from = Math.floor(n * step);
|
|
81
|
+
const to = Math.min(length, from + width);
|
|
82
|
+
out[n] = (sums[to] - sums[from]) / (to - from);
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
/** The share of 40 ms frames with a fundamental in the voice range; hits, whooshes and noise have none. */
|
|
87
|
+
export function voicedShare(channels, sampleRate) {
|
|
88
|
+
const pitched = channels.map((channel) => atPitchRate([channel], sampleRate));
|
|
89
|
+
const frames = Math.floor((pitched[0]?.length ?? 0) / HOP);
|
|
90
|
+
return frames ? channelPitchTrack(pitched).length / frames : 0;
|
|
91
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type HfColorGrading, type ResolvedHfColorGrading } from "./colorGrading.js";
|
|
2
|
+
import { type CubeLut3D } from "./colorLuts.js";
|
|
3
|
+
export interface BakeHfColorGradingCubeOptions {
|
|
4
|
+
/** Points per axis. ffmpeg's lut3d reads 2 to 256. */
|
|
5
|
+
size?: number;
|
|
6
|
+
/** The parsed output LUT, required when the grading names one. */
|
|
7
|
+
lut?: CubeLut3D | null;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Bakes a colour grade into a `.cube` 3D LUT (red varying fastest) that matches the runtime's
|
|
11
|
+
* shader, for applying the grade outside a browser, e.g. with ffmpeg's lut3d. Throws for a grade
|
|
12
|
+
* with spatial stages (grain, vignette, blur, halftone and the other effects), which no LUT holds.
|
|
13
|
+
*/
|
|
14
|
+
export declare function bakeHfColorGradingCube(grading: HfColorGrading | ResolvedHfColorGrading, options?: BakeHfColorGradingCubeOptions): string;
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { calculateHfColorGradingSecondaryMask, hasHfColorGradingHueCurveValues, hasHfColorGradingRgbCurveValues, hasHfColorGradingSecondaryValues, HF_COLOR_GRADING_ACTIVE_EFFECT_KEYS, normalizeHfColorGrading, } from "./colorGrading.js";
|
|
2
|
+
import { compileHfColorCurve, compileHfHueCurve, HF_COLOR_CURVE_LUT_SIZE, } from "./colorGradingCurves.js";
|
|
3
|
+
import { packCubeLutToRgba8, unitFloatToByte } from "./colorLuts.js";
|
|
4
|
+
const LUMA_WEIGHTS = [0.2126, 0.7152, 0.0722];
|
|
5
|
+
const CURVE_MAX_INDEX = HF_COLOR_CURVE_LUT_SIZE - 1;
|
|
6
|
+
const luma = (c) => c[0] * LUMA_WEIGHTS[0] + c[1] * LUMA_WEIGHTS[1] + c[2] * LUMA_WEIGHTS[2];
|
|
7
|
+
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
|
|
8
|
+
const clampRgb = (c) => [clamp(c[0], 0, 1), clamp(c[1], 0, 1), clamp(c[2], 0, 1)];
|
|
9
|
+
const fract = (value) => value - Math.floor(value);
|
|
10
|
+
const mod = (value, divisor) => value - divisor * Math.floor(value / divisor);
|
|
11
|
+
const mix = (a, b, t) => a + (b - a) * t;
|
|
12
|
+
const mixRgb = (a, b, t) => [
|
|
13
|
+
mix(a[0], b[0], t),
|
|
14
|
+
mix(a[1], b[1], t),
|
|
15
|
+
mix(a[2], b[2], t),
|
|
16
|
+
];
|
|
17
|
+
const smoothstep = (edge0, edge1, value) => {
|
|
18
|
+
const t = clamp((value - edge0) / (edge1 - edge0), 0, 1);
|
|
19
|
+
return t * t * (3 - 2 * t);
|
|
20
|
+
};
|
|
21
|
+
const towardLuma = (c, base, amount) => [
|
|
22
|
+
mix(base, c[0], amount),
|
|
23
|
+
mix(base, c[1], amount),
|
|
24
|
+
mix(base, c[2], amount),
|
|
25
|
+
];
|
|
26
|
+
/**
|
|
27
|
+
* A value as the shader reads it back from an 8-bit texture channel. The runtime stages texture
|
|
28
|
+
* data in a Float32Array first, which moves rounding ties: 0.9 packs as 229, not 230.
|
|
29
|
+
*/
|
|
30
|
+
const texel = (value) => unitFloatToByte(Math.fround(value)) / 255;
|
|
31
|
+
/** The runtime's encoding of a signed value into an 8-bit channel. */
|
|
32
|
+
const signedUnit = (value, range) => Math.min(1, Math.max(1 / 255, (value * (127 / range) + 128) / 255));
|
|
33
|
+
const decodeSigned = (value) => (value * 255 - 128) / 127;
|
|
34
|
+
const signedTexel = (value, range) => decodeSigned(texel(signedUnit(value, range)));
|
|
35
|
+
function rgbToHsv(c) {
|
|
36
|
+
const maximum = Math.max(c[0], c[1], c[2]);
|
|
37
|
+
const minimum = Math.min(c[0], c[1], c[2]);
|
|
38
|
+
const delta = maximum - minimum;
|
|
39
|
+
let hue = 0;
|
|
40
|
+
if (delta > 0.00001) {
|
|
41
|
+
if (maximum === c[0])
|
|
42
|
+
hue = mod((c[1] - c[2]) / delta, 6);
|
|
43
|
+
else if (maximum === c[1])
|
|
44
|
+
hue = (c[2] - c[0]) / delta + 2;
|
|
45
|
+
else
|
|
46
|
+
hue = (c[0] - c[1]) / delta + 4;
|
|
47
|
+
hue = fract(hue / 6);
|
|
48
|
+
}
|
|
49
|
+
return [hue, maximum > 0.00001 ? delta / maximum : 0, maximum];
|
|
50
|
+
}
|
|
51
|
+
function hsvToRgb([hue, saturation, value]) {
|
|
52
|
+
const channel = (offset) => {
|
|
53
|
+
const band = Math.abs(fract(hue + offset) * 6 - 3);
|
|
54
|
+
return value * mix(1, clamp(band - 1, 0, 1), saturation);
|
|
55
|
+
};
|
|
56
|
+
return [channel(0), channel(0.6666667), channel(0.3333333)];
|
|
57
|
+
}
|
|
58
|
+
function applyPrimaryGrade(input, adjust) {
|
|
59
|
+
const gain = 2 ** adjust.exposure;
|
|
60
|
+
let c = [input[0] * gain, input[1] * gain, input[2] * gain];
|
|
61
|
+
const y = luma(c);
|
|
62
|
+
const lift = adjust.shadows * 0.35 * (1 - smoothstep(0, 0.65, y)) +
|
|
63
|
+
adjust.highlights * 0.35 * smoothstep(0.35, 1, y);
|
|
64
|
+
const blackPoint = clamp(adjust.blacks * 0.18, -0.18, 0.18);
|
|
65
|
+
const whitePoint = clamp(1 - adjust.whites * 0.18, 0.82, 1.18);
|
|
66
|
+
const range = Math.max(whitePoint - blackPoint, 0.2);
|
|
67
|
+
c = c.map((value) => (value + lift - blackPoint) / range);
|
|
68
|
+
c[0] += adjust.temperature * 0.08 + adjust.tint * 0.04;
|
|
69
|
+
c[2] -= adjust.temperature * 0.08 - adjust.tint * 0.04;
|
|
70
|
+
c[1] -= adjust.tint * 0.08;
|
|
71
|
+
const contrast = Math.max(0, 1 + adjust.contrast);
|
|
72
|
+
c = c.map((value) => (value - 0.5) * contrast + 0.5);
|
|
73
|
+
const satLuma = luma(c);
|
|
74
|
+
const currentSat = clamp(Math.max(...c) - Math.min(...c), 0, 1);
|
|
75
|
+
const skinLike = smoothstep(0.02, 0.18, c[0] - c[1]) *
|
|
76
|
+
smoothstep(0, 0.16, c[1] - c[2]) *
|
|
77
|
+
smoothstep(0.18, 0.82, satLuma);
|
|
78
|
+
const vibranceWeight = (1 - currentSat * 0.72) * mix(1, 0.55, skinLike);
|
|
79
|
+
c = towardLuma(c, satLuma, Math.max(0, 1 + adjust.vibrance * vibranceWeight));
|
|
80
|
+
return towardLuma(c, satLuma, Math.max(0, 1 + adjust.saturation));
|
|
81
|
+
}
|
|
82
|
+
function wheelDirection(hue) {
|
|
83
|
+
const direction = hsvToRgb([fract(hue), 1, 1]);
|
|
84
|
+
const y = luma(direction);
|
|
85
|
+
const centred = direction.map((value) => value - y);
|
|
86
|
+
const scale = Math.max(Math.abs(centred[0]), Math.abs(centred[1]), Math.abs(centred[2]), 0.0001);
|
|
87
|
+
return centred.map((value) => value / scale);
|
|
88
|
+
}
|
|
89
|
+
function applyTonalWheels(color, wheels) {
|
|
90
|
+
const y = luma(color);
|
|
91
|
+
const shadows = 1 - smoothstep(0, 0.6, y);
|
|
92
|
+
const highlights = smoothstep(0.4, 1, y);
|
|
93
|
+
const midtones = Math.max(0, 1 - shadows - highlights);
|
|
94
|
+
const total = Math.max(shadows + midtones + highlights, 0.0001);
|
|
95
|
+
const c = [...color];
|
|
96
|
+
for (const [wheel, weight] of [
|
|
97
|
+
[wheels.shadows, shadows / total],
|
|
98
|
+
[wheels.midtones, midtones / total],
|
|
99
|
+
[wheels.highlights, highlights / total],
|
|
100
|
+
]) {
|
|
101
|
+
const direction = wheelDirection(wheel.hue / 360);
|
|
102
|
+
for (let channel = 0; channel < 3; channel += 1) {
|
|
103
|
+
c[channel] =
|
|
104
|
+
(c[channel] ?? 0) +
|
|
105
|
+
(direction[channel] ?? 0) * wheel.amount * weight * 0.18 +
|
|
106
|
+
wheel.level * weight * 0.25;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return c;
|
|
110
|
+
}
|
|
111
|
+
function quantisedCurve(samples) {
|
|
112
|
+
return Float64Array.from(samples, texel);
|
|
113
|
+
}
|
|
114
|
+
function sampleCurve(samples, coordinate) {
|
|
115
|
+
const position = clamp(coordinate, 0, 1) * CURVE_MAX_INDEX;
|
|
116
|
+
const lower = Math.floor(position);
|
|
117
|
+
const upper = Math.min(lower + 1, CURVE_MAX_INDEX);
|
|
118
|
+
return mix(samples[lower] ?? 0, samples[upper] ?? 0, position - lower);
|
|
119
|
+
}
|
|
120
|
+
function applyRgbCurves(c, curves) {
|
|
121
|
+
return [
|
|
122
|
+
sampleCurve(curves.red, sampleCurve(curves.master, c[0])),
|
|
123
|
+
sampleCurve(curves.green, sampleCurve(curves.master, c[1])),
|
|
124
|
+
sampleCurve(curves.blue, sampleCurve(curves.master, c[2])),
|
|
125
|
+
];
|
|
126
|
+
}
|
|
127
|
+
function signedHueCurve(points, outputMin, outputMax) {
|
|
128
|
+
const samples = points.length >= 3
|
|
129
|
+
? compileHfHueCurve(points, outputMin, outputMax)
|
|
130
|
+
: new Float32Array(HF_COLOR_CURVE_LUT_SIZE);
|
|
131
|
+
return Float64Array.from(samples, (value) => signedTexel(value, outputMax));
|
|
132
|
+
}
|
|
133
|
+
function sampleHueCurve(samples, hue) {
|
|
134
|
+
const position = fract(hue) * HF_COLOR_CURVE_LUT_SIZE;
|
|
135
|
+
const lower = Math.floor(position);
|
|
136
|
+
const upper = (lower + 1) % HF_COLOR_CURVE_LUT_SIZE;
|
|
137
|
+
return mix(samples[lower] ?? 0, samples[upper] ?? 0, position - lower);
|
|
138
|
+
}
|
|
139
|
+
function applyHueCurves(c, curves) {
|
|
140
|
+
const hsv = rgbToHsv(clampRgb(c));
|
|
141
|
+
const originalLuma = luma(c);
|
|
142
|
+
const hueShift = sampleHueCurve(curves.hue, hsv[0]);
|
|
143
|
+
const saturation = sampleHueCurve(curves.saturation, hsv[0]);
|
|
144
|
+
const lumaShift = sampleHueCurve(curves.luma, hsv[0]);
|
|
145
|
+
const shifted = hsvToRgb([
|
|
146
|
+
fract(hsv[0] + hueShift * 0.5),
|
|
147
|
+
clamp(hsv[1] * Math.max(0, 1 + saturation), 0, 1),
|
|
148
|
+
hsv[2],
|
|
149
|
+
]);
|
|
150
|
+
const offset = originalLuma - luma(shifted) + lumaShift;
|
|
151
|
+
return shifted.map((value) => value + offset);
|
|
152
|
+
}
|
|
153
|
+
function quantisedSecondary({ key, correction, }) {
|
|
154
|
+
const range = (soft) => ({
|
|
155
|
+
min: texel(soft.min),
|
|
156
|
+
max: texel(soft.max),
|
|
157
|
+
softness: texel(soft.softness / 0.5) * 0.5,
|
|
158
|
+
});
|
|
159
|
+
return {
|
|
160
|
+
key: {
|
|
161
|
+
hue: {
|
|
162
|
+
center: texel(key.hue.center / 360) * 360,
|
|
163
|
+
range: texel(key.hue.range / 180) * 180,
|
|
164
|
+
softness: texel(key.hue.softness / 180) * 180,
|
|
165
|
+
},
|
|
166
|
+
saturation: range(key.saturation),
|
|
167
|
+
luma: range(key.luma),
|
|
168
|
+
},
|
|
169
|
+
hueShift: signedTexel(correction.hueShift, 180),
|
|
170
|
+
saturation: signedTexel(correction.saturation, 1),
|
|
171
|
+
luma: signedTexel(correction.luma, 1),
|
|
172
|
+
temperature: signedTexel(correction.temperature, 1),
|
|
173
|
+
tint: signedTexel(correction.tint, 1),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
function applySecondary(c, secondary) {
|
|
177
|
+
const hsv = rgbToHsv(clampRgb(c));
|
|
178
|
+
const mask = calculateHfColorGradingSecondaryMask(hsv[0] * 360, hsv[1], luma(c), secondary.key);
|
|
179
|
+
if (mask <= 0)
|
|
180
|
+
return c;
|
|
181
|
+
const rotated = hsvToRgb([fract(hsv[0] + secondary.hueShift * 0.5), hsv[1], hsv[2]]);
|
|
182
|
+
const corrected = towardLuma(rotated, luma(rotated), Math.max(0, 1 + secondary.saturation)).map((value) => value + secondary.luma);
|
|
183
|
+
corrected[0] += secondary.temperature * 0.08 + secondary.tint * 0.04;
|
|
184
|
+
corrected[2] -= secondary.temperature * 0.08 - secondary.tint * 0.04;
|
|
185
|
+
corrected[1] -= secondary.tint * 0.08;
|
|
186
|
+
return mixRgb(c, corrected, mask);
|
|
187
|
+
}
|
|
188
|
+
function sampleLut(lut, r, g, b) {
|
|
189
|
+
const index = (g * lut.width + b * lut.size + r) * 4;
|
|
190
|
+
return [
|
|
191
|
+
(lut.data[index] ?? 0) / 255,
|
|
192
|
+
(lut.data[index + 1] ?? 0) / 255,
|
|
193
|
+
(lut.data[index + 2] ?? 0) / 255,
|
|
194
|
+
];
|
|
195
|
+
}
|
|
196
|
+
function applyLut(color, lut) {
|
|
197
|
+
const top = lut.size - 1;
|
|
198
|
+
const scaled = color.map((value, channel) => clamp((value - (lut.domainMin[channel] ?? 0)) / (lut.span[channel] ?? 1), 0, 1) * top);
|
|
199
|
+
const lo = scaled.map(Math.floor);
|
|
200
|
+
const hi = lo.map((value) => Math.min(value + 1, top));
|
|
201
|
+
const f = scaled.map((value, channel) => value - (lo[channel] ?? 0));
|
|
202
|
+
const c00 = mixRgb(sampleLut(lut, lo[0], lo[1], lo[2]), sampleLut(lut, hi[0], lo[1], lo[2]), f[0]);
|
|
203
|
+
const c10 = mixRgb(sampleLut(lut, lo[0], hi[1], lo[2]), sampleLut(lut, hi[0], hi[1], lo[2]), f[0]);
|
|
204
|
+
const c01 = mixRgb(sampleLut(lut, lo[0], lo[1], hi[2]), sampleLut(lut, hi[0], lo[1], hi[2]), f[0]);
|
|
205
|
+
const c11 = mixRgb(sampleLut(lut, lo[0], hi[1], hi[2]), sampleLut(lut, hi[0], hi[1], hi[2]), f[0]);
|
|
206
|
+
const lutColor = mixRgb(mixRgb(c00, c10, f[1]), mixRgb(c01, c11, f[1]), f[2]);
|
|
207
|
+
return mixRgb(color, lutColor, clamp(lut.intensity, 0, 1));
|
|
208
|
+
}
|
|
209
|
+
function spatialStages(grading) {
|
|
210
|
+
const details = ["vignette", "grain"]
|
|
211
|
+
.filter((key) => Math.abs(grading.details[key]) > 0.0001)
|
|
212
|
+
.map((key) => `details.${key}`);
|
|
213
|
+
const effects = HF_COLOR_GRADING_ACTIVE_EFFECT_KEYS.filter((key) => Math.abs(grading.effects[key]) > 0.0001).map((key) => `effects.${key}`);
|
|
214
|
+
return [...details, ...effects];
|
|
215
|
+
}
|
|
216
|
+
function compileLut(grading, lut) {
|
|
217
|
+
if (!grading.lut || grading.lut.intensity === 0)
|
|
218
|
+
return null;
|
|
219
|
+
if (!lut) {
|
|
220
|
+
throw new Error(`The grading applies the LUT ${grading.lut.src}; pass its parsed .cube as options.lut to bake it.`);
|
|
221
|
+
}
|
|
222
|
+
const packed = packCubeLutToRgba8(lut);
|
|
223
|
+
return {
|
|
224
|
+
size: Math.max(lut.size, 2),
|
|
225
|
+
width: packed.width,
|
|
226
|
+
data: packed.data,
|
|
227
|
+
domainMin: [...lut.domainMin],
|
|
228
|
+
span: lut.domainMax.map((max, channel) => Math.max(max - (lut.domainMin[channel] ?? 0), 0.00001)),
|
|
229
|
+
intensity: grading.lut.intensity ?? 0,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
function compileGrade(grading, lut) {
|
|
233
|
+
const spatial = spatialStages(grading);
|
|
234
|
+
if (spatial.length > 0) {
|
|
235
|
+
throw new Error(`A 3D LUT holds only colour; this grading also has spatial stages: ${spatial.join(", ")}.`);
|
|
236
|
+
}
|
|
237
|
+
const { curves, hueCurves, secondaries } = grading;
|
|
238
|
+
return {
|
|
239
|
+
grading,
|
|
240
|
+
curves: hasHfColorGradingRgbCurveValues(curves)
|
|
241
|
+
? {
|
|
242
|
+
red: quantisedCurve(compileHfColorCurve(curves.red)),
|
|
243
|
+
green: quantisedCurve(compileHfColorCurve(curves.green)),
|
|
244
|
+
blue: quantisedCurve(compileHfColorCurve(curves.blue)),
|
|
245
|
+
master: quantisedCurve(compileHfColorCurve(curves.master)),
|
|
246
|
+
}
|
|
247
|
+
: null,
|
|
248
|
+
hueCurves: hasHfColorGradingHueCurveValues(hueCurves)
|
|
249
|
+
? {
|
|
250
|
+
hue: signedHueCurve(hueCurves.hueVsHue, -180, 180),
|
|
251
|
+
saturation: signedHueCurve(hueCurves.hueVsSaturation, -1, 1),
|
|
252
|
+
luma: signedHueCurve(hueCurves.hueVsLuma, -1, 1),
|
|
253
|
+
}
|
|
254
|
+
: null,
|
|
255
|
+
secondaries: hasHfColorGradingSecondaryValues(secondaries)
|
|
256
|
+
? secondaries.filter((secondary) => secondary.enabled).map(quantisedSecondary)
|
|
257
|
+
: [],
|
|
258
|
+
lut: compileLut(grading, lut),
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
function gradeColor(input, grade) {
|
|
262
|
+
let c = applyPrimaryGrade(input, grade.grading.adjust);
|
|
263
|
+
c = applyTonalWheels(c, grade.grading.wheels);
|
|
264
|
+
if (grade.curves)
|
|
265
|
+
c = applyRgbCurves(c, grade.curves);
|
|
266
|
+
if (grade.hueCurves)
|
|
267
|
+
c = applyHueCurves(c, grade.hueCurves);
|
|
268
|
+
for (const secondary of grade.secondaries)
|
|
269
|
+
c = applySecondary(c, secondary);
|
|
270
|
+
c = clampRgb(c);
|
|
271
|
+
if (grade.lut)
|
|
272
|
+
c = clampRgb(applyLut(c, grade.lut));
|
|
273
|
+
return clampRgb(mixRgb(input, c, grade.grading.intensity));
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Bakes a colour grade into a `.cube` 3D LUT (red varying fastest) that matches the runtime's
|
|
277
|
+
* shader, for applying the grade outside a browser, e.g. with ffmpeg's lut3d. Throws for a grade
|
|
278
|
+
* with spatial stages (grain, vignette, blur, halftone and the other effects), which no LUT holds.
|
|
279
|
+
*/
|
|
280
|
+
export function bakeHfColorGradingCube(grading, options = {}) {
|
|
281
|
+
const size = options.size ?? 33;
|
|
282
|
+
if (!Number.isInteger(size) || size < 2 || size > 256) {
|
|
283
|
+
throw new RangeError(`A .cube size must be a whole number from 2 to 256, not ${size}.`);
|
|
284
|
+
}
|
|
285
|
+
const resolved = normalizeHfColorGrading(grading);
|
|
286
|
+
const grade = resolved ? compileGrade(resolved, options.lut) : null;
|
|
287
|
+
const step = size - 1;
|
|
288
|
+
const lines = [`LUT_3D_SIZE ${size}`, "DOMAIN_MIN 0 0 0", "DOMAIN_MAX 1 1 1"];
|
|
289
|
+
for (let b = 0; b < size; b += 1) {
|
|
290
|
+
for (let g = 0; g < size; g += 1) {
|
|
291
|
+
for (let r = 0; r < size; r += 1) {
|
|
292
|
+
const input = [r / step, g / step, b / step];
|
|
293
|
+
const output = grade ? gradeColor(input, grade) : input;
|
|
294
|
+
lines.push(output.map((value) => value.toFixed(6)).join(" "));
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return `${lines.join("\n")}\n`;
|
|
299
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grade groups: one colour grade for a whole lane. An inert `<hf-color-group>`
|
|
3
|
+
* holds the grade in `data-color-grading`; each `<video>`, `<img>` or `<canvas>`
|
|
4
|
+
* joins with `data-color-group` naming the group's id. Membership is held by the
|
|
5
|
+
* member, as with audio groups, so a camera cut into hundreds of clips is graded
|
|
6
|
+
* by editing one attribute.
|
|
7
|
+
*/
|
|
8
|
+
export declare const HF_COLOR_GROUP_TAG = "hf-color-group";
|
|
9
|
+
export declare const HF_COLOR_GROUP_ATTR = "data-color-group";
|
|
10
|
+
type GroupLookupRoot = Pick<Document, "getElementById"> | null | undefined;
|
|
11
|
+
/**
|
|
12
|
+
* The group a gradable element belongs to, or null. `data-color-group=""` is no
|
|
13
|
+
* group. Tolerant of partial `Element` doubles, as `audioGroupOf` is.
|
|
14
|
+
*/
|
|
15
|
+
export declare function colorGroupOf(el: Element): string | null;
|
|
16
|
+
/**
|
|
17
|
+
* The `<hf-color-group>` for an id, or null. Tag-checked so an unrelated
|
|
18
|
+
* element sharing the id is never read as a lane's grade.
|
|
19
|
+
*/
|
|
20
|
+
export declare function resolveColorGroupElement(root: GroupLookupRoot, groupId: string): Element | null;
|
|
21
|
+
/**
|
|
22
|
+
* The grade that applies to `el`: its own `data-color-grading` when present,
|
|
23
|
+
* else its group element's, else null. A group id with no element is ungrouped.
|
|
24
|
+
*/
|
|
25
|
+
export declare function effectiveColorGrading(el: Element, root: GroupLookupRoot): string | null;
|
|
26
|
+
/**
|
|
27
|
+
* Make `<hf-color-group>` inert, once per document: an unknown custom element
|
|
28
|
+
* still makes a box, and a grade must not shift authored layout. `contents`, so
|
|
29
|
+
* anything nested inside one still renders; `!important` because an author rule
|
|
30
|
+
* can outrank a bare type selector.
|
|
31
|
+
*/
|
|
32
|
+
export declare function ensureColorGroupInertStyle(doc: Document): void;
|
|
33
|
+
export {};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grade groups: one colour grade for a whole lane. An inert `<hf-color-group>`
|
|
3
|
+
* holds the grade in `data-color-grading`; each `<video>`, `<img>` or `<canvas>`
|
|
4
|
+
* joins with `data-color-group` naming the group's id. Membership is held by the
|
|
5
|
+
* member, as with audio groups, so a camera cut into hundreds of clips is graded
|
|
6
|
+
* by editing one attribute.
|
|
7
|
+
*/
|
|
8
|
+
import { COLOR_GROUP_ATTR, COLOR_GROUP_TAG } from "@hyperframes/parsers/color-grading-contract";
|
|
9
|
+
import { HF_COLOR_GRADING_ATTR, HF_COLOR_GRADING_SOURCE_TAGS } from "./colorGrading.js";
|
|
10
|
+
export const HF_COLOR_GROUP_TAG = COLOR_GROUP_TAG;
|
|
11
|
+
export const HF_COLOR_GROUP_ATTR = COLOR_GROUP_ATTR;
|
|
12
|
+
/**
|
|
13
|
+
* The group a gradable element belongs to, or null. `data-color-group=""` is no
|
|
14
|
+
* group. Tolerant of partial `Element` doubles, as `audioGroupOf` is.
|
|
15
|
+
*/
|
|
16
|
+
export function colorGroupOf(el) {
|
|
17
|
+
if (typeof el.tagName !== "string" || typeof el.getAttribute !== "function")
|
|
18
|
+
return null;
|
|
19
|
+
if (!HF_COLOR_GRADING_SOURCE_TAGS.includes(el.tagName.toLowerCase())) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
return el.getAttribute(HF_COLOR_GROUP_ATTR) || null;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The `<hf-color-group>` for an id, or null. Tag-checked so an unrelated
|
|
26
|
+
* element sharing the id is never read as a lane's grade.
|
|
27
|
+
*/
|
|
28
|
+
export function resolveColorGroupElement(root, groupId) {
|
|
29
|
+
const el = root?.getElementById(groupId) ?? null;
|
|
30
|
+
return el?.tagName.toLowerCase() === HF_COLOR_GROUP_TAG ? el : null;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The grade that applies to `el`: its own `data-color-grading` when present,
|
|
34
|
+
* else its group element's, else null. A group id with no element is ungrouped.
|
|
35
|
+
*/
|
|
36
|
+
export function effectiveColorGrading(el, root) {
|
|
37
|
+
const own = el.getAttribute(HF_COLOR_GRADING_ATTR);
|
|
38
|
+
if (own !== null)
|
|
39
|
+
return own;
|
|
40
|
+
const groupId = colorGroupOf(el);
|
|
41
|
+
if (!groupId)
|
|
42
|
+
return null;
|
|
43
|
+
return resolveColorGroupElement(root, groupId)?.getAttribute(HF_COLOR_GRADING_ATTR) ?? null;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Make `<hf-color-group>` inert, once per document: an unknown custom element
|
|
47
|
+
* still makes a box, and a grade must not shift authored layout. `contents`, so
|
|
48
|
+
* anything nested inside one still renders; `!important` because an author rule
|
|
49
|
+
* can outrank a bare type selector.
|
|
50
|
+
*/
|
|
51
|
+
export function ensureColorGroupInertStyle(doc) {
|
|
52
|
+
const styleId = "__hf-color-group-inert";
|
|
53
|
+
if (!doc?.head || doc.getElementById(styleId))
|
|
54
|
+
return;
|
|
55
|
+
const style = doc.createElement("style");
|
|
56
|
+
style.id = styleId;
|
|
57
|
+
style.textContent = `${HF_COLOR_GROUP_TAG}{display:contents!important}`;
|
|
58
|
+
doc.head.appendChild(style);
|
|
59
|
+
}
|
|
@@ -888,7 +888,11 @@ async function bundleProject(projectDir, options) {
|
|
|
888
888
|
const rawHtml = readSource(mountedShell ?? entryHtml);
|
|
889
889
|
const compiled = await compileHtml(rawHtml, sourceDir, options?.probeMediaDuration);
|
|
890
890
|
if (options?.staticGuard !== false && !mountedShell) {
|
|
891
|
-
const
|
|
891
|
+
const entryRel = relative(projectDir, indexPath).replace(/\\/g, "/");
|
|
892
|
+
const staticGuard = await validateHyperframeHtmlContract(compiled, {
|
|
893
|
+
dir: projectDir,
|
|
894
|
+
...(entryRel !== "index.html" && { compSrcPath: entryRel }),
|
|
895
|
+
});
|
|
892
896
|
if (!staticGuard.isValid) {
|
|
893
897
|
console.warn(`[StaticGuard] Invalid HyperFrame contract: ${staticGuard.missingKeys.join("; ")}`);
|
|
894
898
|
}
|
|
@@ -4,4 +4,7 @@ export type HyperframeStaticGuardResult = {
|
|
|
4
4
|
missingKeys: string[];
|
|
5
5
|
failureReason: HyperframeStaticFailureReason | null;
|
|
6
6
|
};
|
|
7
|
-
export declare function validateHyperframeHtmlContract(html: string
|
|
7
|
+
export declare function validateHyperframeHtmlContract(html: string, project?: {
|
|
8
|
+
dir: string;
|
|
9
|
+
compSrcPath?: string;
|
|
10
|
+
}): Promise<HyperframeStaticGuardResult>;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { lintHyperframeHtml } from "@hyperframes/lint";
|
|
2
|
-
export async function validateHyperframeHtmlContract(html) {
|
|
3
|
-
const result = await lintHyperframeHtml(html
|
|
1
|
+
import { collectExternalStyles, lintHyperframeHtml } from "@hyperframes/lint";
|
|
2
|
+
export async function validateHyperframeHtmlContract(html, project) {
|
|
3
|
+
const result = await lintHyperframeHtml(html, project && {
|
|
4
|
+
externalStyles: collectExternalStyles(project.dir, html, project.compSrcPath),
|
|
5
|
+
});
|
|
4
6
|
const missingKeys = result.findings
|
|
5
7
|
.filter((finding) => finding.severity === "error")
|
|
6
8
|
.map((finding) => finding.message);
|
|
@@ -36,7 +36,7 @@ export declare function parseFontFamilyValue(value: string): string[];
|
|
|
36
36
|
*/
|
|
37
37
|
export declare function normalizeSystemFontPrimaryFamilies(html: string, deterministicPrimary?: string): string;
|
|
38
38
|
/** Surfaces font-family is declared on in served HTML. */
|
|
39
|
-
export type FontFamilySurface = "font-family" | "data-font-family";
|
|
39
|
+
export type FontFamilySurface = "font-family" | "font" | "data-font-family";
|
|
40
40
|
export type FontFamilyDeclaration = {
|
|
41
41
|
surface: FontFamilySurface;
|
|
42
42
|
declaration: string;
|
|
@@ -213,16 +213,30 @@ function collectCssCustomProperties(css, customProperties) {
|
|
|
213
213
|
customProperties.set(decl.prop, decl.value);
|
|
214
214
|
});
|
|
215
215
|
}
|
|
216
|
+
const FONT_SIZE_KEYWORD = /^(?:xx-small|x-small|small|medium|large|x-large|xx-large|xxx-large|smaller|larger)$/i;
|
|
217
|
+
const FONT_SIZE_TOKEN = /^(?:\d*\.?\d+[a-z%]+|(?:calc|min|max|clamp|var)\(.*\))(?:\/\S+)?$/i;
|
|
218
|
+
function fontShorthandFamilies(value) {
|
|
219
|
+
for (const token of value.matchAll(/(?:[^\s(]|\([^)]*\))+/g)) {
|
|
220
|
+
const size = token[0].split("/")[0] ?? "";
|
|
221
|
+
if (FONT_SIZE_KEYWORD.test(size) || FONT_SIZE_TOKEN.test(token[0])) {
|
|
222
|
+
return value.slice(token.index + token[0].length).trim();
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return "";
|
|
226
|
+
}
|
|
216
227
|
function* iterateCssRootFontFamilyDeclarations(root) {
|
|
217
228
|
const declarations = [];
|
|
218
229
|
root.walkDecls((decl) => {
|
|
219
|
-
|
|
230
|
+
const prop = decl.prop.toLowerCase();
|
|
231
|
+
if (prop !== "font-family" && prop !== "font")
|
|
220
232
|
return;
|
|
221
233
|
if (isFontFaceDeclaration(decl))
|
|
222
234
|
return;
|
|
223
|
-
const declaration = decl.value;
|
|
235
|
+
const declaration = prop === "font" ? fontShorthandFamilies(decl.value) : decl.value;
|
|
236
|
+
if (!declaration)
|
|
237
|
+
return;
|
|
224
238
|
declarations.push({
|
|
225
|
-
surface:
|
|
239
|
+
surface: prop,
|
|
226
240
|
declaration,
|
|
227
241
|
families: parseFontFamilyValue(declaration),
|
|
228
242
|
});
|
|
@@ -230,7 +244,7 @@ function* iterateCssRootFontFamilyDeclarations(root) {
|
|
|
230
244
|
yield* declarations;
|
|
231
245
|
}
|
|
232
246
|
function* iterateCssFontFamilyDeclarations(css) {
|
|
233
|
-
if (!/font
|
|
247
|
+
if (!/font(?:-family)?\s*:/i.test(css))
|
|
234
248
|
return;
|
|
235
249
|
const root = parseCssRoot(css);
|
|
236
250
|
if (!root)
|