egaki 0.9.0 → 0.10.0
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/CHANGELOG.md +25 -0
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +39 -0
- package/dist/cli/dev-server.d.ts +30 -0
- package/dist/cli/dev-server.d.ts.map +1 -0
- package/dist/cli/dev-server.js +266 -0
- package/dist/cli/dev-server.test.d.ts +2 -0
- package/dist/cli/dev-server.test.d.ts.map +1 -0
- package/dist/cli/dev-server.test.js +95 -0
- package/dist/vite/angled-screen-shader.d.ts +5 -0
- package/dist/vite/angled-screen-shader.d.ts.map +1 -1
- package/dist/vite/angled-screen-shader.js +77 -34
- package/dist/vite/vite-plugin.d.ts.map +1 -1
- package/dist/vite/vite-plugin.js +108 -9
- package/package.json +2 -6
- package/src/cli/cli.ts +50 -0
- package/src/cli/dev-server.test.ts +109 -0
- package/src/cli/dev-server.ts +303 -0
- package/src/vite/angled-screen-shader.tsx +81 -34
- package/src/vite/vite-plugin.ts +117 -9
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
// Zero-config dev server for egaki MDX videos.
|
|
2
|
+
//
|
|
3
|
+
// `egaki dev video.mdx` boots a Vite dev server with inline config
|
|
4
|
+
// (configFile: false) — no package.json, vite.config.ts, or npm install
|
|
5
|
+
// needed in the user's folder.
|
|
6
|
+
//
|
|
7
|
+
// Dependency resolution: when the folder has no usable node_modules, a
|
|
8
|
+
// shim node_modules is created containing symlinks to egaki itself and
|
|
9
|
+
// every egaki dependency (react, remotion, vite, ...) resolved from the
|
|
10
|
+
// CLI's own installation. A plain fallback resolveId plugin is NOT enough
|
|
11
|
+
// because Vite's dep optimizer resolves optimizeDeps.include entries with
|
|
12
|
+
// an internal resolver (alias + vite:resolve only) that skips user
|
|
13
|
+
// plugins — without real node_modules those includes fail and CJS deps
|
|
14
|
+
// like safe-mdx break in the browser. The shim makes standard node
|
|
15
|
+
// resolution work everywhere (vite:resolve, optimizer, tailwind scans).
|
|
16
|
+
// The shim dir contains a .egaki-shim marker so it can be refreshed
|
|
17
|
+
// idempotently and recognized as CLI-owned.
|
|
18
|
+
//
|
|
19
|
+
// Vite's cache dir is placed under ~/.cache/egaki/vite/<hash> so repeated
|
|
20
|
+
// runs reuse the prebundle without polluting the user's folder further.
|
|
21
|
+
|
|
22
|
+
import fs from 'node:fs'
|
|
23
|
+
import net from 'node:net'
|
|
24
|
+
import os from 'node:os'
|
|
25
|
+
import path from 'node:path'
|
|
26
|
+
import crypto from 'node:crypto'
|
|
27
|
+
import { createRequire } from 'node:module'
|
|
28
|
+
import { fileURLToPath } from 'node:url'
|
|
29
|
+
import type { ViteDevServer } from 'vite'
|
|
30
|
+
import { video } from '../vite/vite-plugin.ts'
|
|
31
|
+
|
|
32
|
+
// Package root: this file runs from src/cli/ (dev) or dist/cli/ (published).
|
|
33
|
+
// Going up 2 levels reaches the egaki package root either way.
|
|
34
|
+
const __pkgRoot = path.resolve(fileURLToPath(new URL('.', import.meta.url)), '../..')
|
|
35
|
+
const egakiRequire = createRequire(path.join(__pkgRoot, 'package.json'))
|
|
36
|
+
|
|
37
|
+
const SHIM_MARKER = '.egaki-shim'
|
|
38
|
+
|
|
39
|
+
export interface StartDevServerOptions {
|
|
40
|
+
/** Path to an .mdx entry file, or a directory containing .mdx files.
|
|
41
|
+
* Defaults to the current working directory (auto-discovers video.mdx
|
|
42
|
+
* > index.mdx > first .mdx alphabetically). */
|
|
43
|
+
entry?: string
|
|
44
|
+
/** Fixed port. When omitted, a random free port is chosen. */
|
|
45
|
+
port?: number
|
|
46
|
+
/** Host to bind. Defaults to localhost. */
|
|
47
|
+
host?: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface RunningDevServer {
|
|
51
|
+
url: string
|
|
52
|
+
port: number
|
|
53
|
+
root: string
|
|
54
|
+
server: ViteDevServer
|
|
55
|
+
close: () => Promise<void>
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Find a random free TCP port by binding to port 0. */
|
|
59
|
+
export async function findFreePort(host = '127.0.0.1'): Promise<number> {
|
|
60
|
+
return new Promise((resolve, reject) => {
|
|
61
|
+
const probe = net.createServer()
|
|
62
|
+
probe.once('error', reject)
|
|
63
|
+
probe.listen(0, host, () => {
|
|
64
|
+
const address = probe.address() as net.AddressInfo
|
|
65
|
+
probe.close(() => resolve(address.port))
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Locate the real directory of a package installed in egaki's own tree.
|
|
71
|
+
* Tries pkg/package.json first (works for most packages), then falls
|
|
72
|
+
* back to resolving the main entry and walking up to the package root
|
|
73
|
+
* (for packages whose exports map hides ./package.json). */
|
|
74
|
+
function packageDirFromEgaki(pkg: string): string | undefined {
|
|
75
|
+
try {
|
|
76
|
+
return path.dirname(fs.realpathSync(egakiRequire.resolve(`${pkg}/package.json`)))
|
|
77
|
+
} catch {
|
|
78
|
+
// Fall through to entry-based lookup
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
let dir = path.dirname(fs.realpathSync(egakiRequire.resolve(pkg)))
|
|
82
|
+
while (true) {
|
|
83
|
+
const pkgJsonPath = path.join(dir, 'package.json')
|
|
84
|
+
if (fs.existsSync(pkgJsonPath)) {
|
|
85
|
+
const name = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')).name
|
|
86
|
+
if (name === pkg) return dir
|
|
87
|
+
}
|
|
88
|
+
const parent = path.dirname(dir)
|
|
89
|
+
if (parent === dir) return undefined
|
|
90
|
+
dir = parent
|
|
91
|
+
}
|
|
92
|
+
} catch {
|
|
93
|
+
return undefined
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** All packages the shim should link: egaki itself plus every dependency
|
|
98
|
+
* and (present) optional/peer dependency of the egaki package. */
|
|
99
|
+
function shimPackageDirs(): Map<string, string> {
|
|
100
|
+
const pkgJson = JSON.parse(
|
|
101
|
+
fs.readFileSync(path.join(__pkgRoot, 'package.json'), 'utf-8'),
|
|
102
|
+
)
|
|
103
|
+
const names = new Set<string>([
|
|
104
|
+
...Object.keys(pkgJson.dependencies ?? {}),
|
|
105
|
+
...Object.keys(pkgJson.optionalDependencies ?? {}),
|
|
106
|
+
...Object.keys(pkgJson.peerDependencies ?? {}),
|
|
107
|
+
])
|
|
108
|
+
const dirs = new Map<string, string>()
|
|
109
|
+
dirs.set('egaki', fs.realpathSync(__pkgRoot))
|
|
110
|
+
for (const name of names) {
|
|
111
|
+
const dir = packageDirFromEgaki(name)
|
|
112
|
+
if (dir) dirs.set(name, dir)
|
|
113
|
+
}
|
|
114
|
+
return dirs
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Ensure bare imports resolve from the project root.
|
|
118
|
+
*
|
|
119
|
+
* - egaki already resolvable from root → real project, do nothing
|
|
120
|
+
* - no node_modules (or a CLI-owned shim) → (re)create the shim
|
|
121
|
+
* - foreign node_modules without egaki → error with guidance
|
|
122
|
+
*/
|
|
123
|
+
export function ensureDependencyShim(root: string): Error | 'normal' | 'shim' {
|
|
124
|
+
const nodeModulesDir = path.join(root, 'node_modules')
|
|
125
|
+
const isShim = fs.existsSync(path.join(nodeModulesDir, SHIM_MARKER))
|
|
126
|
+
|
|
127
|
+
if (!isShim) {
|
|
128
|
+
// Walk up from root looking for node_modules/egaki — a plain fs walk
|
|
129
|
+
// instead of createRequire().resolve() because test runners (tsx,
|
|
130
|
+
// vitest) patch Node's resolver and make it resolve 'egaki' from the
|
|
131
|
+
// wrong context, silently skipping shim creation.
|
|
132
|
+
let dir = root
|
|
133
|
+
while (true) {
|
|
134
|
+
if (fs.existsSync(path.join(dir, 'node_modules', 'egaki', 'package.json'))) {
|
|
135
|
+
return 'normal'
|
|
136
|
+
}
|
|
137
|
+
const parent = path.dirname(dir)
|
|
138
|
+
if (parent === dir) break
|
|
139
|
+
dir = parent
|
|
140
|
+
}
|
|
141
|
+
if (fs.existsSync(nodeModulesDir)) {
|
|
142
|
+
return new Error(
|
|
143
|
+
`${nodeModulesDir} exists but 'egaki' is not installed there.\n` +
|
|
144
|
+
`Either run 'npm install egaki' in ${root}, or remove node_modules ` +
|
|
145
|
+
`to let 'egaki dev' manage dependencies automatically.`,
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
fs.mkdirSync(nodeModulesDir, { recursive: true })
|
|
151
|
+
fs.writeFileSync(
|
|
152
|
+
path.join(nodeModulesDir, SHIM_MARKER),
|
|
153
|
+
'This node_modules was generated by `egaki dev`. Safe to delete.\n',
|
|
154
|
+
)
|
|
155
|
+
for (const [name, targetDir] of shimPackageDirs()) {
|
|
156
|
+
const linkPath = path.join(nodeModulesDir, name)
|
|
157
|
+
fs.mkdirSync(path.dirname(linkPath), { recursive: true })
|
|
158
|
+
const existing = fs.lstatSync(linkPath, { throwIfNoEntry: false })
|
|
159
|
+
if (existing) {
|
|
160
|
+
if (!existing.isSymbolicLink()) continue
|
|
161
|
+
if (fs.readlinkSync(linkPath) === targetDir) continue
|
|
162
|
+
fs.unlinkSync(linkPath)
|
|
163
|
+
}
|
|
164
|
+
fs.symlinkSync(targetDir, linkPath, 'dir')
|
|
165
|
+
}
|
|
166
|
+
return 'shim'
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Walk up from a real path and collect the parent of every ancestor
|
|
170
|
+
* directory named node_modules. These parents are safe fs.allow roots
|
|
171
|
+
* that cover hoisted/pnpm-store dependency locations. */
|
|
172
|
+
function nodeModulesAncestorRoots(realPath: string): string[] {
|
|
173
|
+
const roots: string[] = []
|
|
174
|
+
let dir = realPath
|
|
175
|
+
while (true) {
|
|
176
|
+
const parent = path.dirname(dir)
|
|
177
|
+
if (parent === dir) break
|
|
178
|
+
if (path.basename(dir) === 'node_modules') roots.push(parent)
|
|
179
|
+
dir = parent
|
|
180
|
+
}
|
|
181
|
+
return roots
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Compute fs.allow roots so Vite can serve framework + dependency files
|
|
185
|
+
* that live outside the user's project root (in the CLI's install tree). */
|
|
186
|
+
function computeFsAllow(root: string): string[] {
|
|
187
|
+
const allow = new Set<string>([root])
|
|
188
|
+
const probes = [path.join(__pkgRoot, 'package.json')]
|
|
189
|
+
for (const id of ['react', 'react-dom', 'remotion', 'vite', 'spiceflow']) {
|
|
190
|
+
try {
|
|
191
|
+
probes.push(egakiRequire.resolve(id))
|
|
192
|
+
} catch {
|
|
193
|
+
// Probe missing: skip
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
for (const probe of probes) {
|
|
197
|
+
try {
|
|
198
|
+
const real = fs.realpathSync(probe)
|
|
199
|
+
allow.add(path.dirname(real))
|
|
200
|
+
for (const ancestorRoot of nodeModulesAncestorRoots(real)) {
|
|
201
|
+
allow.add(ancestorRoot)
|
|
202
|
+
}
|
|
203
|
+
} catch {
|
|
204
|
+
// Probe missing: skip
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
allow.add(fs.realpathSync(__pkgRoot))
|
|
208
|
+
return [...allow]
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Resolve the entry option to { root, entryFile }.
|
|
212
|
+
* Accepts a file path, a directory, or nothing (cwd). */
|
|
213
|
+
function resolveEntry(entry: string | undefined): Error | { root: string; entryFile?: string } {
|
|
214
|
+
const target = path.resolve(process.cwd(), entry ?? '.')
|
|
215
|
+
if (!fs.existsSync(target)) {
|
|
216
|
+
return new Error(`entry not found: ${target}`)
|
|
217
|
+
}
|
|
218
|
+
const stat = fs.statSync(target)
|
|
219
|
+
if (stat.isDirectory()) {
|
|
220
|
+
const hasMdx = fs
|
|
221
|
+
.readdirSync(target)
|
|
222
|
+
.some((name) => name.endsWith('.mdx'))
|
|
223
|
+
if (!hasMdx) {
|
|
224
|
+
return new Error(
|
|
225
|
+
`no .mdx files found in ${target}\nCreate a video.mdx file or pass one explicitly: egaki dev path/to/video.mdx`,
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
return { root: target }
|
|
229
|
+
}
|
|
230
|
+
if (!/\.mdx$/.test(target)) {
|
|
231
|
+
return new Error(`entry must be an .mdx file or a directory, got: ${target}`)
|
|
232
|
+
}
|
|
233
|
+
return { root: path.dirname(target), entryFile: target }
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Warm up the server: the very first request can 500 transiently while
|
|
237
|
+
* the dep optimizer discovers new deps and reloads the ssr program (a
|
|
238
|
+
* browser recovers via auto-reload, but agents/tests hit the URL once).
|
|
239
|
+
* Retry until the page serves 200 so the returned URL is ready to use. */
|
|
240
|
+
async function warmUp(url: string, timeoutMs: number): Promise<void> {
|
|
241
|
+
const deadline = Date.now() + timeoutMs
|
|
242
|
+
while (Date.now() < deadline) {
|
|
243
|
+
try {
|
|
244
|
+
const response = await fetch(url)
|
|
245
|
+
if (response.ok) return
|
|
246
|
+
} catch {
|
|
247
|
+
// Server not accepting connections yet
|
|
248
|
+
}
|
|
249
|
+
await new Promise((resolve) => setTimeout(resolve, 300))
|
|
250
|
+
}
|
|
251
|
+
console.warn(`[egaki] dev server warmup did not reach 200 within ${timeoutMs}ms`)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Start the zero-config dev server. Returns Error | RunningDevServer. */
|
|
255
|
+
export async function startDevServer(
|
|
256
|
+
options: StartDevServerOptions = {},
|
|
257
|
+
): Promise<Error | RunningDevServer> {
|
|
258
|
+
const entryResult = resolveEntry(options.entry)
|
|
259
|
+
if (entryResult instanceof Error) return entryResult
|
|
260
|
+
const { root, entryFile } = entryResult
|
|
261
|
+
|
|
262
|
+
const shimResult = ensureDependencyShim(root)
|
|
263
|
+
if (shimResult instanceof Error) return shimResult
|
|
264
|
+
|
|
265
|
+
const host = options.host ?? 'localhost'
|
|
266
|
+
const port = options.port ?? (await findFreePort())
|
|
267
|
+
|
|
268
|
+
const rootHash = crypto.createHash('sha256').update(root).digest('hex').slice(0, 12)
|
|
269
|
+
const cacheDir = path.join(os.homedir(), '.cache', 'egaki', 'vite', rootHash)
|
|
270
|
+
|
|
271
|
+
const { createServer } = await import('vite')
|
|
272
|
+
|
|
273
|
+
const server = await createServer({
|
|
274
|
+
configFile: false,
|
|
275
|
+
root,
|
|
276
|
+
cacheDir,
|
|
277
|
+
plugins: [video(entryFile ? { entry: entryFile } : undefined)],
|
|
278
|
+
resolve: {
|
|
279
|
+
dedupe: ['react', 'react-dom', 'remotion', '@remotion/player', '@remotion/media'],
|
|
280
|
+
},
|
|
281
|
+
server: {
|
|
282
|
+
port,
|
|
283
|
+
strictPort: true,
|
|
284
|
+
host,
|
|
285
|
+
fs: {
|
|
286
|
+
allow: computeFsAllow(root),
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
await server.listen()
|
|
292
|
+
|
|
293
|
+
const url = `http://${host}:${port}/`
|
|
294
|
+
await warmUp(url, 60_000)
|
|
295
|
+
|
|
296
|
+
return {
|
|
297
|
+
url,
|
|
298
|
+
port,
|
|
299
|
+
root,
|
|
300
|
+
server,
|
|
301
|
+
close: () => server.close(),
|
|
302
|
+
}
|
|
303
|
+
}
|
|
@@ -11,7 +11,10 @@
|
|
|
11
11
|
* intersection → plane-local UV → sample DOM texture
|
|
12
12
|
*
|
|
13
13
|
* The intersection distance gives true per-pixel depth, which drives:
|
|
14
|
-
* - bokeh blur (
|
|
14
|
+
* - bokeh blur (sparse 6-blade hex iris, radius grows with distance from
|
|
15
|
+
* focus — fewer taps so the aperture shape reads as hard disks instead
|
|
16
|
+
* of smoothing into a gaussian)
|
|
17
|
+
* - chromatic aberration (radial R/B split, stronger in the bokeh)
|
|
15
18
|
* - fog toward backgroundColor (like the original Three.js plugin's
|
|
16
19
|
* scene fog, unframer-private/plugin-angled-screen BokehPass setup)
|
|
17
20
|
* - film grain (from the plugin's filmGrainShader, u_time-seeded)
|
|
@@ -113,6 +116,11 @@ export interface AngledScreenProps {
|
|
|
113
116
|
fog?: number
|
|
114
117
|
/** Film grain intensity. Default 0.02. */
|
|
115
118
|
grainIntensity?: number
|
|
119
|
+
/** Chromatic aberration strength. RGB channels separate along the radial
|
|
120
|
+
* axis from frame center, scaled up by local blur so fringing reads
|
|
121
|
+
* strongest in the bokeh (like real lens CA on the reference photos).
|
|
122
|
+
* 0 = off. Default 0.45. */
|
|
123
|
+
chromaticAberration?: number
|
|
116
124
|
|
|
117
125
|
/** Background color behind the tilted screen. Default '#000000'. */
|
|
118
126
|
backgroundColor?: string
|
|
@@ -159,6 +167,7 @@ uniform float u_focusDist; // focus distance in px along the ray
|
|
|
159
167
|
uniform float u_aspect; // width / height, for circular bokeh
|
|
160
168
|
uniform float u_fog;
|
|
161
169
|
uniform float u_grain;
|
|
170
|
+
uniform float u_ca; // chromatic aberration strength
|
|
162
171
|
uniform vec4 u_background;
|
|
163
172
|
uniform float u_debug;
|
|
164
173
|
uniform float u_time;
|
|
@@ -170,21 +179,26 @@ float rand(vec2 co) {
|
|
|
170
179
|
return fract(sin(dot(co, vec2(12.9898, 78.233))) * 43758.5453);
|
|
171
180
|
}
|
|
172
181
|
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
vec2(
|
|
182
|
+
// Sparse 6-blade hexagonal iris. Far fewer taps than the 41-sample Martins
|
|
183
|
+
// Upitis kernel so the aperture SHAPE reads as discrete hex disks instead of
|
|
184
|
+
// smoothing into a gaussian. Outer ring is weighted heavier so bright points
|
|
185
|
+
// form hard-edged bokeh blobs (the look of real lens DOF on the refs).
|
|
186
|
+
// Unit circle coords at radius 1.0 / 0.55.
|
|
187
|
+
const vec2 HEX_OUTER[6] = vec2[](
|
|
188
|
+
vec2( 1.0000, 0.0000),
|
|
189
|
+
vec2( 0.5000, 0.8660),
|
|
190
|
+
vec2(-0.5000, 0.8660),
|
|
191
|
+
vec2(-1.0000, 0.0000),
|
|
192
|
+
vec2(-0.5000, -0.8660),
|
|
193
|
+
vec2( 0.5000, -0.8660)
|
|
180
194
|
);
|
|
181
|
-
const vec2
|
|
182
|
-
vec2(
|
|
183
|
-
vec2(
|
|
184
|
-
)
|
|
185
|
-
|
|
186
|
-
vec2(
|
|
187
|
-
vec2(
|
|
195
|
+
const vec2 HEX_MID[6] = vec2[](
|
|
196
|
+
vec2( 0.5500, 0.0000),
|
|
197
|
+
vec2( 0.2750, 0.4763),
|
|
198
|
+
vec2(-0.2750, 0.4763),
|
|
199
|
+
vec2(-0.5500, 0.0000),
|
|
200
|
+
vec2(-0.2750, -0.4763),
|
|
201
|
+
vec2( 0.2750, -0.4763)
|
|
188
202
|
);
|
|
189
203
|
|
|
190
204
|
// Intersect the camera ray through screen uv suv with the tilted plane.
|
|
@@ -219,6 +233,17 @@ vec4 sampleScene(vec2 suv) {
|
|
|
219
233
|
return mix(u_background, c, coverage);
|
|
220
234
|
}
|
|
221
235
|
|
|
236
|
+
// Per-channel sample with chromatic aberration: R and B pull apart along
|
|
237
|
+
// the radial axis from frame center (classic lens CA purple/cyan fringing).
|
|
238
|
+
vec4 sampleSceneCA(vec2 suv, vec2 caOffset) {
|
|
239
|
+
float r = sampleScene(suv + caOffset).r;
|
|
240
|
+
float g = sampleScene(suv).g;
|
|
241
|
+
float b = sampleScene(suv - caOffset).b;
|
|
242
|
+
// Coverage/alpha from the green (unshifted) sample keeps silhouette stable.
|
|
243
|
+
vec4 mid = sampleScene(suv);
|
|
244
|
+
return vec4(r, g, b, mid.a);
|
|
245
|
+
}
|
|
246
|
+
|
|
222
247
|
void main() {
|
|
223
248
|
vec4 bg = u_background;
|
|
224
249
|
|
|
@@ -243,27 +268,41 @@ void main() {
|
|
|
243
268
|
// stays tack sharp. Background pixels (no hit) count as far away, so the
|
|
244
269
|
// plane silhouette melts softly where it is out of focus.
|
|
245
270
|
float factor = hit ? max(t - u_focusDist, 0.0) / u_perspective : 1.0;
|
|
246
|
-
|
|
271
|
+
float blurAmt = clamp(factor * u_aperture, 0.0, u_maxblur) * u_bokehEnabled;
|
|
272
|
+
vec2 dofblur = vec2(blurAmt);
|
|
247
273
|
|
|
248
274
|
vec2 aspectcorrect = vec2(1.0, u_aspect);
|
|
249
|
-
vec2 dofblur9 = dofblur * 0.9;
|
|
250
|
-
vec2 dofblur7 = dofblur * 0.7;
|
|
251
|
-
vec2 dofblur4 = dofblur * 0.4;
|
|
252
275
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
276
|
+
// Radial CA direction from frame center. Strength grows with local blur so
|
|
277
|
+
// sharp regions stay clean and only the bokeh fringes with purple/cyan.
|
|
278
|
+
vec2 fromCenter = v_uv - 0.5;
|
|
279
|
+
float radial = length(fromCenter);
|
|
280
|
+
vec2 caDir = radial > 1e-5 ? fromCenter / radial : vec2(1.0, 0.0);
|
|
281
|
+
// Base fringe + blur-scaled fringe. u_ca ~0.45 reads like a real lens on
|
|
282
|
+
// the reference photos without turning the whole frame into a prism.
|
|
283
|
+
float caAmt = u_ca * (0.0015 + blurAmt * 0.55);
|
|
284
|
+
vec2 caOffset = caDir * caAmt * aspectcorrect;
|
|
285
|
+
|
|
286
|
+
// Sparse hex gather. Outer ring weight 2.0 so the 6-blade iris is visible
|
|
287
|
+
// as hard bokeh disks; mid ring 1.0 fills the disk; center 1.0 anchors.
|
|
288
|
+
// Total weight = 6*2 + 6*1 + 1 = 19.
|
|
289
|
+
const float W_OUTER = 2.0;
|
|
290
|
+
const float W_MID = 1.0;
|
|
291
|
+
const float W_CENTER = 1.0;
|
|
292
|
+
vec4 col = sampleSceneCA(v_uv, caOffset) * W_CENTER;
|
|
293
|
+
for (int i = 0; i < 6; i++) {
|
|
294
|
+
col += sampleSceneCA(v_uv + HEX_OUTER[i] * aspectcorrect * dofblur, caOffset) * W_OUTER;
|
|
262
295
|
}
|
|
263
|
-
for (int i = 0; i <
|
|
264
|
-
col +=
|
|
296
|
+
for (int i = 0; i < 6; i++) {
|
|
297
|
+
col += sampleSceneCA(v_uv + HEX_MID[i] * aspectcorrect * dofblur, caOffset) * W_MID;
|
|
265
298
|
}
|
|
266
|
-
col /=
|
|
299
|
+
col /= (6.0 * W_OUTER + 6.0 * W_MID + W_CENTER);
|
|
300
|
+
|
|
301
|
+
// Mild highlight bias: bright samples punch through more (classic optical
|
|
302
|
+
// bokeh where speculars form bright disks instead of averaging down).
|
|
303
|
+
// Applied as a soft lift so it doesn't blow whites on already-bright UI.
|
|
304
|
+
float luma = dot(col.rgb, vec3(0.299, 0.587, 0.114));
|
|
305
|
+
col.rgb += col.rgb * blurAmt * 1.8 * smoothstep(0.55, 0.95, luma);
|
|
267
306
|
|
|
268
307
|
// Fog toward background with depth (plugin's THREE.Fog equivalent)
|
|
269
308
|
col.rgb = mix(col.rgb, bg.rgb, u_fog * depth01 * (hit ? 1.0 : 0.0));
|
|
@@ -305,6 +344,7 @@ const UNIFORM_NAMES = [
|
|
|
305
344
|
'u_aspect',
|
|
306
345
|
'u_fog',
|
|
307
346
|
'u_grain',
|
|
347
|
+
'u_ca',
|
|
308
348
|
'u_background',
|
|
309
349
|
'u_debug',
|
|
310
350
|
'u_time',
|
|
@@ -393,13 +433,19 @@ export function AngledScreen(props: AngledScreenProps) {
|
|
|
393
433
|
step: 0.01,
|
|
394
434
|
},
|
|
395
435
|
fog: { value: props.fog ?? 0.35, min: 0, max: 1, step: 0.01 },
|
|
396
|
-
grainIntensity: { value: props.grainIntensity ?? 0.02, min: 0, max: 0.
|
|
436
|
+
grainIntensity: { value: props.grainIntensity ?? 0.02, min: 0, max: 0.3, step: 0.005 },
|
|
437
|
+
chromaticAberration: {
|
|
438
|
+
value: props.chromaticAberration ?? 0.45,
|
|
439
|
+
min: 0,
|
|
440
|
+
max: 2,
|
|
441
|
+
step: 0.01,
|
|
442
|
+
},
|
|
397
443
|
backgroundColor: props.backgroundColor ?? '#000000',
|
|
398
444
|
debug: props.debug ?? false,
|
|
399
445
|
})
|
|
400
446
|
|
|
401
447
|
const { perspective, rotateX, rotateY, rotateZ, translateX, translateZ } = tp
|
|
402
|
-
const { aperture, maxBlur, focus, fog, grainIntensity } = tp
|
|
448
|
+
const { aperture, maxBlur, focus, fog, grainIntensity, chromaticAberration } = tp
|
|
403
449
|
const { bokeh, debug, backgroundColor } = tp
|
|
404
450
|
const time = frame / fps
|
|
405
451
|
|
|
@@ -557,6 +603,7 @@ export function AngledScreen(props: AngledScreenProps) {
|
|
|
557
603
|
if (locs.u_aspect) gl.uniform1f(locs.u_aspect, compW / compH)
|
|
558
604
|
if (locs.u_fog) gl.uniform1f(locs.u_fog, fog)
|
|
559
605
|
if (locs.u_grain) gl.uniform1f(locs.u_grain, grainIntensity)
|
|
606
|
+
if (locs.u_ca) gl.uniform1f(locs.u_ca, chromaticAberration)
|
|
560
607
|
if (locs.u_background) gl.uniform4fv(locs.u_background, parseHexColor(backgroundColor))
|
|
561
608
|
if (locs.u_debug) gl.uniform1f(locs.u_debug, debug ? 1 : 0)
|
|
562
609
|
if (locs.u_time) gl.uniform1f(locs.u_time, time)
|
|
@@ -594,7 +641,7 @@ export function AngledScreen(props: AngledScreenProps) {
|
|
|
594
641
|
},
|
|
595
642
|
[
|
|
596
643
|
compW, compH, perspective, rotateX, rotateY, rotateZ, translateX, translateZ,
|
|
597
|
-
bokeh, aperture, maxBlur, focus, fog, grainIntensity,
|
|
644
|
+
bokeh, aperture, maxBlur, focus, fog, grainIntensity, chromaticAberration,
|
|
598
645
|
backgroundColor, debug, time,
|
|
599
646
|
],
|
|
600
647
|
)
|