Repository navigation
Expand file tree
/
Copy pathkuro.html
More file actions
1385 lines (1286 loc) · 117 KB
/
Copy pathkuro.html
File metadata and controls
1385 lines (1286 loc) · 117 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Kuro — その映像の、本当の色を。|RustyWorks</title>
<link rel="canonical" href="https://rustyworks.jp/kuro.html">
<meta name="description" content="プロ品質のカラーグレーディングを積んだ、macOS 用の動画プレーヤー。再生しながら、リファレンス基準の色へ。本体無料、Kuro PRO は買い切り。">
<meta property="og:type" content="website">
<meta property="og:site_name" content="RustyWorks">
<meta property="og:locale" content="ja_JP">
<meta property="og:url" content="https://rustyworks.jp/kuro.html">
<meta property="og:title" content="Kuro — その映像の、本当の色を。">
<meta property="og:description" content="プロ品質のカラーグレーディングを積んだ macOS 用の動画プレーヤー。ブラウザでそのまま試せます。">
<meta property="og:image" content="https://rustyworks.jp/assets/og/kuro-og.jpg">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Kuro — その映像の、本当の色を。">
<meta name="twitter:description" content="プロ品質のカラーグレーディングを積んだ macOS 用の動画プレーヤー。ブラウザでそのまま試せます。">
<meta name="twitter:image" content="https://rustyworks.jp/assets/og/kuro-og.jpg">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Zen+Kaku+Gothic+New:wght@400;500;700;900&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #0a0a0b; }
a { color: #e5484d; text-decoration: none; }
a:hover { color: #ff6b6f; }
@keyframes kuroScan { 0% { transform: translateX(-100%) } 100% { transform: translateX(1400%) } }
.stage { position:relative; margin-top:34px; border:1px solid rgba(255,255,255,.1); border-radius:12px;
overflow:hidden; background:#0d0d10 center/cover no-repeat; cursor:ew-resize;
aspect-ratio:16/9;
/* pan-y, NOT none: the divider needs horizontal drags, but `none`
also swallows vertical swipes, and on a phone this element fills
most of the screen — the page would refuse to scroll wherever the
reader happened to put their thumb. */
touch-action:pan-y; }
/* The poster sits on the stage background and the canvas fades in over it, so
the section is never a black hole while a multi-megabyte clip downloads. */
.stage canvas { display:block; width:100%; height:auto; opacity:0; transition:opacity .25s; }
.stage.ready canvas { opacity:1; }
.loading { position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
font-family:'IBM Plex Mono',monospace; font-size:12px; color:#f2f2f0; letter-spacing:.1em;
background:rgba(10,10,11,.45); backdrop-filter:blur(2px); transition:opacity .25s; }
.stage.ready .loading { opacity:0; pointer-events:none; }
.tag { position:absolute; top:12px; font-family:'IBM Plex Mono',monospace; font-size:11px; letter-spacing:.08em;
padding:5px 10px; border-radius:5px; background:rgba(10,10,11,.72); color:#c4c4c0; pointer-events:none;
backdrop-filter:blur(6px); }
.tag.l { left:12px; } .tag.r { right:12px; color:#f2f2f0; border:1px solid rgba(229,72,77,.55); }
.divider { position:absolute; top:0; bottom:0; width:2px; background:#e5484d; pointer-events:none; }
.knob { position:absolute; top:50%; width:34px; height:34px; margin:-17px 0 0 -17px; border-radius:50%;
background:#e5484d; pointer-events:none; display:flex; align-items:center; justify-content:center;
color:#0a0a0b; font-size:13px; font-weight:700; }
.hint { position:absolute; left:50%; bottom:12px; transform:translateX(-50%); font-family:'IBM Plex Mono',monospace;
font-size:11px; color:#c4c4c0; background:rgba(10,10,11,.72); padding:5px 12px; border-radius:20px;
pointer-events:none; backdrop-filter:blur(6px); transition:opacity .4s; }
.panel { margin-top:22px; display:grid; grid-template-columns:repeat(auto-fit,minmax(250px,1fr)); gap:14px 28px;
border:1px solid rgba(255,255,255,.08); border-radius:12px; background:#0d0d10; padding:24px; }
.row { display:flex; align-items:center; gap:12px; }
.row label { font-family:'Zen Kaku Gothic New',sans-serif; font-size:13px; color:#c4c4c0; width:92px; flex:none; }
.row input[type=range] { flex:1; accent-color:#e5484d; min-width:0; }
.row .val { font-family:'IBM Plex Mono',monospace; font-size:12px; color:#7a7a76; width:46px; text-align:right; flex:none; }
.clips { margin-top:16px; display:flex; gap:8px; flex-wrap:wrap; }
.clips button { font-family:'Zen Kaku Gothic New',sans-serif; font-size:13px; font-weight:500;
padding:8px 15px; border-radius:20px; }
.clips button[aria-pressed="true"] { border-color:#e5484d; color:#e5484d; }
.actions { margin-top:18px; display:flex; gap:10px; flex-wrap:wrap; align-items:center; }
button { font-family:'Space Grotesk',sans-serif; font-size:14px; font-weight:600; padding:11px 20px; border-radius:8px;
border:1px solid rgba(255,255,255,.16); background:transparent; color:#f2f2f0; cursor:pointer;
transition:transform .07s ease, background .15s, border-color .15s, color .15s, opacity .15s; }
button.primary { background:#e5484d; border-color:#e5484d; color:#0a0a0b; }
button:hover:not(:disabled) { border-color:#e5484d; }
/* Pressed state — without this the button gives no feedback at all, and with
an animated transition the change can read as "nothing happened". */
button:active:not(:disabled) { transform:scale(.96); filter:brightness(.88); }
button:focus-visible { outline:2px solid #e5484d; outline-offset:2px; }
button:disabled { opacity:.38; cursor:default; }
/* Auto-adjust, once its values are actually in effect: flips from a filled
call-to-action to an outlined "this is on" state, so the button reports
whether the grade is currently the auto one. */
#auto15 { min-width:132px; }
button.primary.applied { background:transparent; color:#e5484d; }
.note { font-family:'Zen Kaku Gothic New',sans-serif; font-size:13px; line-height:1.8; color:#7a7a76; margin-top:22px; }
.fallback { padding:40px; text-align:center; font-family:'Zen Kaku Gothic New',sans-serif; color:#a4a4a0; }
/* --- Language switch ------------------------------------------------------
The choice is shared across the whole site (localStorage `rw-lang`), so a
reader who picked EN on the top page arrives here already in English.
These rules have to undo the page's global `button`, which is styled for
the demo's controls — a bordered pill either side of the slash. */
.lang { display:flex; align-items:center; gap:2px; font-family:'IBM Plex Mono',monospace; font-size:12px; }
.lang button { font:inherit; background:none; border:0; padding:4px 6px; border-radius:4px;
color:#6a6a66; cursor:pointer; transition:color .15s, background-color .15s; }
.lang button:hover { color:#f2f2f0; background:rgba(255,255,255,.06); }
.lang button[aria-pressed="true"] { color:#f2f2f0; font-weight:500; }
.lang .sep { color:#3a3a38; }
/* --- Narrow screens -------------------------------------------------------
The page is laid out with a fixed 72px gutter, which on a 390px phone left
the content 246px wide — the interactive demo ended up SMALLER than it had
been on its own page, and a 96px headline wrapped one character per line.
These overrides only apply below 780px, so the desktop layout is untouched.
!important is needed because the values they replace are inline. */
@media (max-width: 780px) {
.pad { padding-left:20px !important; padding-right:20px !important; }
.hero { height:auto !important; padding-bottom:44px !important; }
.hero-h { font-size:40px !important; line-height:1.15 !important; }
.h-xl { font-size:32px !important; }
.h-l { font-size:27px !important; }
.h-m { font-size:23px !important; }
.two-col { grid-template-columns:1fr !important; gap:24px !important; }
.stats { flex-wrap:wrap !important; }
.stats > div { flex:1 1 50% !important; padding:20px 12px !important; }
.navlinks { gap:16px !important; flex-wrap:wrap !important; justify-content:flex-end !important;
font-size:12px !important; }
.pricecard { padding:28px !important; }
.pricecard ul { grid-template-columns:1fr !important; }
/* Section rhythm: the vertical padding is as oversized as the horizontal. */
#try, #features, #pro { padding-top:56px !important; padding-bottom:48px !important; }
#dl { padding-top:64px !important; padding-bottom:64px !important; }
}
</style>
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-4092261578757214"
crossorigin="anonymous"></script>
</head>
<body>
<!-- Language, resolved before anything else runs.
It has to be this early because the demo below writes its own button
and readout text from JS: by the time the switcher at the foot of the page
walks the DOM, those strings do not exist yet. So the demos read `RW.lang`
as they build, and re-paint through `RW.onChange`. -->
<script>
window.RW = (function () {
// 言語は強い順に決める。1. URL の ?lang 2. 前回の選択 3. 既定は日本語。
// ブラウザの言語では切り替えない。クローラーは en を名乗ることが多く、
// 日本語で書いたページが英語で索引されてしまうため。
var KEY = 'rw-lang', saved = null;
try {
var q = new URLSearchParams(location.search).get('lang');
if (q === 'en' || q === 'ja') { saved = q; localStorage.setItem(KEY, q); }
} catch (e) {}
if (!saved) { try { saved = localStorage.getItem(KEY); } catch (e) {} }
if (!saved) saved = 'ja';
var subs = [];
return {
key: KEY,
lang: saved,
/** Pick one side of a { ja:…, en:… } pair. */
t: function (pair) { return pair[this.lang]; },
/** Register a repaint for text this page writes from JS. */
onChange: function (fn) { subs.push(fn); },
notify: function () { for (var i = 0; i < subs.length; i++) subs[i](); }
};
})();
</script>
<div style="background:#0a0a0b;font-family:'Space Grotesk','Zen Kaku Gothic New',sans-serif;overflow:hidden;">
<!-- nav -->
<div class="pad" style="display:flex;align-items:center;justify-content:space-between;padding:24px 72px;background:#050506;border-bottom:1px solid rgba(255,255,255,.06);position:sticky;top:0;z-index:20;">
<div style="display:flex;align-items:center;gap:11px;color:#f2f2f0;font-size:21px;font-weight:700;letter-spacing:.12em;">
<span style="width:20px;height:20px;border-radius:5px;background:#0a0a0b;border:1.5px solid #e5484d;display:inline-block;"></span>KURO
</div>
<div class="navlinks" style="display:flex;align-items:center;gap:24px;font-family:'IBM Plex Mono',monospace;font-size:13px;color:#8a8a86;">
<a href="index.html" style="color:#8a8a86;">RustyWorks</a>
<a href="#try" style="color:#8a8a86;" data-ja="試す" data-en="Try">試す</a>
<a href="#play" style="color:#8a8a86;" data-ja="再生" data-en="Play">再生</a>
<a href="#color" style="color:#8a8a86;" data-ja="色" data-en="Color">色</a>
<a href="#quality" style="color:#8a8a86;" data-ja="画質" data-en="Quality">画質</a>
<a href="#scopes" style="color:#8a8a86;" data-ja="スコープ" data-en="Scopes">スコープ</a>
<a href="#features" style="color:#8a8a86;" data-ja="全機能" data-en="Features">全機能</a>
<a href="#pro" style="color:#8a8a86;">PRO</a>
<div class="lang" role="group" aria-label="Language">
<button type="button" data-lang="ja" aria-pressed="true">JA</button>
<span class="sep">/</span>
<button type="button" data-lang="en" aria-pressed="false">EN</button>
</div>
<a href="#dl" style="background:#e5484d;color:#0a0a0b;font-weight:600;padding:9px 18px;border-radius:6px;" data-ja="無料DL" data-en="Get it free">無料DL</a>
</div>
</div>
<!-- full-bleed hero -->
<div class="hero" style="position:relative;height:660px;background:repeating-linear-gradient(135deg,#111114 0 16px,#0c0c0f 16px 32px);display:flex;flex-direction:column;">
<div style="height:52px;background:#050506;"></div>
<div class="pad" style="flex:1;position:relative;display:flex;flex-direction:column;justify-content:center;padding:0 72px;overflow:hidden;">
<div style="position:absolute;top:0;bottom:0;left:0;width:140px;background:linear-gradient(90deg,#e5484d,transparent);opacity:.06;"></div>
<div style="position:absolute;top:0;left:0;height:2px;width:60px;background:#e5484d;animation:kuroScan 4s linear infinite;"></div>
<div style="font-family:'IBM Plex Mono',monospace;font-size:12px;letter-spacing:.3em;color:#e5484d;margin-bottom:22px;">A VIDEO PLAYER, GRADED · macOS</div>
<h1 class="hero-h" style="font-size:96px;line-height:.98;font-weight:700;color:#f2f2f0;letter-spacing:-.03em;max-width:920px;white-space:pre-line;" data-ja="その映像の、 本当の色を。" data-en="See the color that is really there.">その映像の、
本当の色を。</h1>
<p style="font-family:'Zen Kaku Gothic New',sans-serif;font-size:19px;line-height:1.7;color:#c4c4c0;max-width:580px;margin-top:28px;" data-ja="プロ品質のカラーグレーディングを積んだ、macOS 用の動画プレーヤー。再生しながら、リファレンス基準の色へ。" data-en="A macOS video player with pro-grade color built in. Grade to the reference while it plays.">プロ品質のカラーグレーディングを積んだ、macOS 用の動画プレーヤー。再生しながら、リファレンス基準の色へ。</p>
<div style="display:flex;gap:16px;align-items:center;margin-top:36px;">
<a href="#dl" style="background:#e5484d;color:#0a0a0b;font-weight:600;font-size:16px;padding:17px 34px;border-radius:8px;" data-ja="無料ダウンロード" data-en="Download free">無料ダウンロード</a>
<a href="https://apps.apple.com/app/apple-store/id6782262572?pt=128819275&ct=website&mt=8" style="font-family:'IBM Plex Mono',monospace;font-size:13px;color:#a4a4a0;text-decoration:underline;text-underline-offset:3px;text-decoration-color:rgba(255,255,255,.25);" data-ja="Mac App Store で配信中" data-en="On the Mac App Store">Mac App Store で配信中</a>
</div>
</div>
<div style="height:52px;background:#050506;"></div>
</div>
<!-- stat strip -->
<div class="stats" style="display:flex;border-bottom:1px solid rgba(255,255,255,.06);">
<div style="flex:1;padding:32px;text-align:center;border-right:1px solid rgba(255,255,255,.06);"><div style="font-size:26px;color:#f2f2f0;font-weight:700;">Rec.709</div><div style="font-family:'IBM Plex Mono',monospace;font-size:11px;color:#7a7a76;margin-top:6px;" data-ja="Gamma 2.4 準拠" data-en="Gamma 2.4 compliant">Gamma 2.4 準拠</div></div>
<div style="flex:1;padding:32px;text-align:center;border-right:1px solid rgba(255,255,255,.06);"><div style="font-size:26px;color:#f2f2f0;font-weight:700;">HDR→SDR</div><div style="font-family:'IBM Plex Mono',monospace;font-size:11px;color:#7a7a76;margin-top:6px;" data-ja="実測で決めたトーンカーブ" data-en="A tone curve settled by measurement">実測で決めたトーンカーブ</div></div>
<div style="flex:1;padding:32px;text-align:center;border-right:1px solid rgba(255,255,255,.06);"><div style="font-size:26px;color:#f2f2f0;font-weight:700;">Metal</div><div style="font-family:'IBM Plex Mono',monospace;font-size:11px;color:#7a7a76;margin-top:6px;" data-ja="自前レンダリング" data-en="Custom render pipeline">自前レンダリング</div></div>
<div style="flex:1;padding:32px;text-align:center;"><div style="font-size:26px;color:#f2f2f0;font-weight:700;">libmpv</div><div style="font-family:'IBM Plex Mono',monospace;font-size:11px;color:#7a7a76;margin-top:6px;" data-ja="幅広い形式に対応" data-en="Broad format support">幅広い形式に対応</div></div>
</div>
<!-- interactive demo — 1.5 auto-adjust -->
<div class="pad" id="try" style="padding:88px 72px 72px;border-bottom:1px solid rgba(255,255,255,.06);">
<div style="max-width:1080px;margin:0 auto;">
<div style="font-family:'IBM Plex Mono',monospace;font-size:12px;letter-spacing:.2em;color:#e5484d;">TRY IT IN YOUR BROWSER</div>
<h2 class="h-l" style="font-size:40px;color:#f2f2f0;font-weight:700;letter-spacing:-.01em;margin:14px 0 12px;" data-ja="測ってから、決める。" data-en="Measure first, then decide.">測ってから、決める。</h2>
<p style="font-family:'Zen Kaku Gothic New',sans-serif;font-size:16px;line-height:1.75;color:#a4a4a0;max-width:660px;">
<span data-ja="左半分が元の映像、右半分が Kuro の自動調整です。仕切りをドラッグして見比べてください。Kuro は画面を 16×16 に区切って1マスずつ明るさと色を測り、そこから露出・黒レベル・白を決めます。クリップごとに用意しておいた値ではありません。スライダーで自分で追い込むこともできます。 " data-en="The left half is the footage as it came, the right half is Kuro's auto-adjust. Drag the divider to compare. Kuro divides the frame into 16×16 cells, measures brightness and color in each one, and decides exposure, black level and white from that — nothing here was prepared per clip. You can also push it further yourself with the sliders. ">左半分が元の映像、右半分が Kuro の自動調整です。仕切りをドラッグして見比べてください。
Kuro は画面を 16×16 に区切って1マスずつ明るさと色を測り、そこから露出・黒レベル・白を決めます。
クリップごとに用意しておいた値ではありません。スライダーで自分で追い込むこともできます。 </span><span style="color:#e5484d;" data-ja="下に出ている数字は、いまこのフレームから実際に測った値です。" data-en="The numbers below are measured from this very frame, right now.">下に出ている数字は、いまこのフレームから実際に測った値です。</span>
</p>
<div class="stage" id="stage15">
<canvas id="gl15"></canvas>
<canvas id="zonesCv15" style="position:absolute;inset:0;width:100%;height:100%;opacity:0;transition:opacity .2s;pointer-events:none;"></canvas>
<span class="tag l" data-ja="元映像" data-en="Original">元映像</span>
<span class="tag r">Kuro 1.5</span>
<div class="divider" id="divider15"></div>
<div class="knob" id="knob15">⇔</div>
<div class="hint" id="hint15" data-ja="ドラッグして比較" data-en="Drag to compare">ドラッグして比較</div>
<div class="loading" id="loading15" data-ja="読み込み中…" data-en="Loading…">読み込み中…</div>
</div>
<div class="clips" id="clips15"></div>
<div class="actions">
<button class="primary" id="auto15" data-ja="自動調整" data-en="Auto-adjust">自動調整</button>
<button id="reset15" data-ja="リセット" data-en="Reset">リセット</button>
<button id="zoneBtn15">測光ゾーンを表示</button>
<label style="font-family:'Zen Kaku Gothic New',sans-serif;font-size:13px;color:#a4a4a0;display:flex;align-items:center;gap:10px;">
<span data-ja="中和" data-en="Neutralize">中和</span>
<input type="range" id="neutral15" min="0" max="1" step="0.05" value="0.5" style="width:150px;">
<span class="val" id="neutral15_v" style="font-family:'IBM Plex Mono',monospace;font-size:12px;color:#7a7a76;">0.50</span>
</label>
<span style="font-family:'Zen Kaku Gothic New',sans-serif;font-size:12px;color:#7a7a76;" data-ja="0 にすると、その場の色みを残したままにします。" data-en="At 0, the color of the light on the scene is left as it is.">0 にすると、その場の色みを残したままにします。</span>
</div>
<!-- The free panel's controls, in the app's own ranges. Auto-adjust
decides exposure / black level / white; these ride on top of it,
exactly as they do in the app. -->
<div class="panel" id="panel15">
<div class="row"><label data-ja="露出" data-en="Exposure">露出</label><input type="range" id="m_gain" min="0" max="4" step="0.01" value="1"><span class="val" id="m_gain_v">1.00</span></div>
<div class="row"><label data-ja="黒レベル" data-en="Black level">黒レベル</label><input type="range" id="m_lift" min="-0.5" max="0.5" step="0.002" value="0"><span class="val" id="m_lift_v">0.000</span></div>
<div class="row"><label data-ja="ガンマ" data-en="Gamma">ガンマ</label><input type="range" id="m_gamma" min="-0.5" max="1" step="0.01" value="0"><span class="val" id="m_gamma_v">0.00</span></div>
<div class="row"><label data-ja="コントラスト" data-en="Contrast">コントラスト</label><input type="range" id="m_con" min="0" max="2" step="0.005" value="1"><span class="val" id="m_con_v">1.000</span></div>
<div class="row"><label data-ja="色温度" data-en="Temperature">色温度</label><input type="range" id="m_temp" min="-4000" max="4000" step="10" value="0"><span class="val" id="m_temp_v">0</span></div>
<div class="row"><label data-ja="ティント" data-en="Tint">ティント</label><input type="range" id="m_tint" min="-100" max="100" step="1" value="0"><span class="val" id="m_tint_v">0</span></div>
<div class="row"><label data-ja="彩度" data-en="Saturation">彩度</label><input type="range" id="m_sat" min="0" max="100" step="1" value="50"><span class="val" id="m_sat_v">50</span></div>
</div>
<div id="readout15" style="margin-top:22px;display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px 26px;font-family:'IBM Plex Mono',monospace;font-size:12px;color:#7a7a76;"></div>
<p class="note">
<span data-ja="※ 自動調整が決めるのは " data-en="Note: auto-adjust decides only ">※ 自動調整が決めるのは </span><span style="color:#a4a4a0;font-weight:600;" data-ja="露出・黒レベル・ホワイトバランス" data-en="exposure, black level and white balance">露出・黒レベル・ホワイトバランス</span><span data-ja="だけです。コントラストと彩度には触れません — そこは手で決めるところだからです。ゾーン測光・黒体軌跡による白の推定・色温度で表しきれない分の残差ゲイン・カット検出、それにスライダー7本の色処理は、すべてアプリと同じ計算をそのまま移植しています(Rec.709 Gamma 2.4 で符号化した空間でのリフト/ガンマ/ゲイン、実測に基づく色温度の RGB ゲイン、輝度を保つ彩度、ピボット 0.435 のコントラスト)。アップスケールやノイズ除去、HDR 表示はブラウザでは再現できないため含まれていません。" data-en=". It leaves contrast and saturation alone — those are yours to decide. Zone metering, white estimated from the blackbody locus, the residual gain for what temperature alone cannot express, cut detection, and the color math behind all seven sliders are ported straight from the app (lift / gamma / gain in the Rec.709 Gamma 2.4 encoded space, measured RGB gains for temperature, luma-preserving saturation, contrast about a 0.435 pivot). Upscaling, noise reduction and HDR display cannot be reproduced in a browser, so they are not here.">だけです。
コントラストと彩度には触れません — そこは手で決めるところだからです。ゾーン測光・黒体軌跡による白の推定・
色温度で表しきれない分の残差ゲイン・カット検出、それにスライダー7本の色処理は、すべてアプリと同じ計算を
そのまま移植しています(Rec.709 Gamma 2.4 で符号化した空間でのリフト/ガンマ/ゲイン、実測に基づく色温度の
RGB ゲイン、輝度を保つ彩度、ピボット 0.435 のコントラスト)。アップスケールやノイズ除去、HDR 表示は
ブラウザでは再現できないため含まれていません。</span>
</p>
<div style="margin-top:26px;display:flex;gap:14px;align-items:center;flex-wrap:wrap;">
<a href="https://apps.apple.com/app/apple-store/id6782262572?pt=128819275&ct=website&mt=8" style="background:#e5484d;color:#0a0a0b;font-weight:600;font-size:16px;padding:15px 30px;border-radius:8px;" data-ja="Mac App Store で無料ダウンロード" data-en="Download free on the Mac App Store">Mac App Store で無料ダウンロード</a>
<span style="font-family:'Zen Kaku Gothic New',sans-serif;font-size:14px;color:#7a7a76;" data-ja="アプリなら、これを自分の動画で。ここにあるスライダーは無料版のものです。" data-en="In the app, this runs on your own footage. Every slider here is in the free version.">アプリなら、これを自分の動画で。ここにあるスライダーは無料版のものです。</span>
</div>
</div>
</div>
<video id="vid15" playsinline muted loop preload="none" style="display:none"></video>
<script>
(function () {
'use strict';
const stage = document.getElementById('stage15');
const canvas = document.getElementById('gl15');
const video = document.getElementById('vid15');
let gl = null, started = false;
// ---- text this demo writes itself --------------------------------------
// The data-ja/data-en pass at the foot of the page cannot own any of this:
// it walks the DOM once, and every string below is written by JS afterwards.
const T = p => RW.t(p);
const STR = {
showZones: { ja:'測光ゾーンを表示', en:'Show metering zones' },
hideZones: { ja:'測光ゾーンを隠す', en:'Hide metering zones' },
noWebGL: { ja:'お使いのブラウザは WebGL2 に対応していないため、このデモを表示できません。',
en:'This browser does not support WebGL2, so this demo cannot run.' },
noFloat: { ja:'このブラウザは浮動小数点のレンダーターゲットに対応していないため、測光を実行できません。',
en:'This browser does not support floating-point render targets, so it cannot meter.' },
};
// The readout. Labels on the left, and the fragments that get spliced into
// the values on the right.
const RD = {
metered: { ja:'測光値(線形輝度)', en:'Metered value (linear luma)' },
exposure: { ja:'露出(べき)', en:'Exposure (power)' },
black: { ja:'黒レベル', en:'Black level' },
cct: { ja:'色温度', en:'Temperature' },
confidence: { ja:'白の確からしさ', en:'Confidence in the white' },
tempTint: { ja:'色温度 / ティント', en:'Temperature / tint' },
residual: { ja:'残差ゲイン', en:'Residual gain' },
channel: { ja:'実効チャンネルゲイン', en:'Effective channel gains' },
zones: { ja:'測光に使ったゾーン', en:'Zones used for metering' },
state: { ja:'状態', en:'State' },
noEstimate: { ja:'推定できず', en:'Could not estimate' },
clipped: { ja:'白飛び ', en:'clipped ' },
backlit: { ja:'逆光', en:'Backlit' },
flat: { ja:'平坦', en:'Flat' },
hlLimited: { ja:'白飛び制限', en:'Highlight-limited' },
cut: { ja:'カット', en:'Cut' },
};
// ---- clips: four clips the rebuilt auto-adjust actually has work to do on.
// Chosen by running this very code over the candidates and keeping the ones
// where the metering moves: moss is badly underexposed, logs is backlit on
// every frame, sea and river have lifted blacks. Nothing is prepared per
// clip — what you see is measured live, here, in your browser.
const CLIPS = [
{ label:{ ja:'逆光の林道', en:'Backlit track' }, file:'assets/lp/demo15-logs.mp4', poster:'assets/lp/demo15-logs.jpg' },
{ label:{ ja:'暗い樹皮', en:'Dark bark' }, file:'assets/lp/demo15-moss.mp4', poster:'assets/lp/demo15-moss.jpg' },
{ label:{ ja:'渓流', en:'Mountain river' },file:'assets/lp/demo15-river.mp4', poster:'assets/lp/demo15-river.jpg' },
{ label:{ ja:'夕景', en:'Sunset' }, file:'assets/lp/demo15-sea.mp4', poster:'assets/lp/demo15-sea.jpg' },
];
let clipIndex = 0;
// =========================================================================
// Port of the app's colour maths. Names match the Swift so the two can be
// diffed by eye: WhiteBalance, Chromaticity, ZoneMetering, ZoneAWB,
// AutoAdjustEngine.
// =========================================================================
// ---- WhiteBalance.channelGains() ----------------------------------------
const ANCH_T = [-2000,-1000,0,1000,2000];
const T_R = [0.740,0.840,1.0,1.257,1.735];
const T_G = [1.016,1.016,1.0,0.970,0.907];
const T_B = [1.700,1.360,1.0,0.660,0.327];
const ANCH_U = [-100,0,50,100];
const U_R = [0.711,1.0,1.177,1.349];
const U_G = [1.117,1.0,0.945,0.874];
const U_B = [0.757,1.0,1.136,1.283];
function interp(x, xs, ys) {
const n = xs.length;
if (x <= xs[0]) return Math.max(0, ys[0] + (ys[1]-ys[0])/(xs[1]-xs[0]) * (x-xs[0]));
if (x >= xs[n-1]) return Math.max(0, ys[n-1] + (ys[n-1]-ys[n-2])/(xs[n-1]-xs[n-2]) * (x-xs[n-1]));
for (let i = 1; i < n; i++) if (x <= xs[i])
return ys[i-1] + (x-xs[i-1])/(xs[i]-xs[i-1]) * (ys[i]-ys[i-1]);
return ys[n-1];
}
function channelGains(temp, tint) {
const t = temp * 4000, u = tint * 100;
return [ interp(t,ANCH_T,T_R) * interp(u,ANCH_U,U_R),
interp(t,ANCH_T,T_G) * interp(u,ANCH_U,U_G),
interp(t,ANCH_T,T_B) * interp(u,ANCH_U,U_B) ];
}
const LUMA = [0.2126, 0.7152, 0.0722];
function normLuma(g) {
const w = g[0]*LUMA[0] + g[1]*LUMA[1] + g[2]*LUMA[2];
return w > 1e-6 ? [g[0]/w, g[1]/w, g[2]/w] : g.slice();
}
function clampf(v, lo, hi) { return Math.min(Math.max(v, lo), hi); }
// AutoAdjust.whiteBalanceMatching — coarse sweep then refine, exactly as the
// app does it. The table is not invertible in closed form, so this is a fit.
function whiteBalanceMatching(desired) {
const target = normLuma(desired);
let best = [0, 0], bestErr = Infinity;
function search(tLo, tHi, uLo, uHi, steps) {
for (let i = 0; i <= steps; i++) {
const t = tLo + (tHi - tLo) * i / steps;
for (let j = 0; j <= steps; j++) {
const u = uLo + (uHi - uLo) * j / steps;
const g = normLuma(channelGains(t, u));
const e = (g[0]-target[0])**2 + (g[1]-target[1])**2 + (g[2]-target[2])**2;
if (e < bestErr) { bestErr = e; best = [t, u]; }
}
}
}
search(-1, 1, -1, 1, 20);
const r = 0.1;
search(Math.max(-1,best[0]-r), Math.min(1,best[0]+r),
Math.max(-1,best[1]-r), Math.min(1,best[1]+r), 10);
return best; // [temperature, tint]
}
// ---- Chromaticity -------------------------------------------------------
const D65_XY = [0.3127, 0.3290], D65_CCT = 6504;
function xyFromLinear(rgb) {
const X = 0.4124564*rgb[0] + 0.3575761*rgb[1] + 0.1804375*rgb[2];
const Y = 0.2126729*rgb[0] + 0.7151522*rgb[1] + 0.0721750*rgb[2];
const Z = 0.0193339*rgb[0] + 0.1191920*rgb[1] + 0.9503041*rgb[2];
const s = X + Y + Z;
return s > 1e-9 ? [X/s, Y/s] : null;
}
function uv1960(xy) {
const d = -2*xy[0] + 12*xy[1] + 3;
return [4*xy[0]/d, 6*xy[1]/d];
}
function linearFromXY(xy) {
if (xy[1] <= 1e-6) return [1,1,1];
const X = xy[0]/xy[1], Y = 1, Z = (1 - xy[0] - xy[1])/xy[1];
return [ 3.2404542*X - 1.5371385*Y - 0.4985314*Z,
-0.9692660*X + 1.8760108*Y + 0.0415560*Z,
0.0556434*X - 0.2040259*Y + 1.0572252*Z ];
}
// CIE 1931 CMFs, multi-lobe Gaussian fits (Wyman/Sloan/Shirley 2013) — the
// same ones the app carries, so no 471-row table to mistype.
function gauss(x, mu, s1, s2) { const s = x < mu ? s1 : s2, t = (x-mu)/s; return Math.exp(-0.5*t*t); }
const xBar = l => 1.056*gauss(l,599.8,37.9,31.0) + 0.362*gauss(l,442.0,16.0,26.7) - 0.065*gauss(l,501.1,20.4,26.2);
const yBar = l => 0.821*gauss(l,568.8,46.9,40.5) + 0.286*gauss(l,530.9,16.3,31.1);
const zBar = l => 1.217*gauss(l,437.0,11.8,36.0) + 0.681*gauss(l,459.0,26.0,13.8);
const C2 = 1.4388e-2;
function planckianXY(T) {
let X = 0, Y = 0, Z = 0;
for (let nm = 360; nm <= 830; nm++) {
const lam = nm * 1e-9;
const b = 1 / (Math.pow(lam,5) * (Math.exp(C2/(lam*T)) - 1));
X += b * xBar(nm); Y += b * yBar(nm); Z += b * zBar(nm);
}
const s = X + Y + Z;
return s > 0 ? [X/s, Y/s] : D65_XY;
}
// Built once on first measurement, not at page load: a reader who never
// scrolls here should not pay for 740 Planck integrations.
let LOCUS = null;
function locus() {
if (!LOCUS) {
LOCUS = [];
for (let T = 1500; T <= 20000; T += 25) {
const uv = uv1960(planckianXY(T));
LOCUS.push({ cct:T, u:uv[0], v:uv[1] });
}
}
return LOCUS;
}
function temperatureOf(xy) {
const uv = uv1960(xy);
let best = null, bestD2 = Infinity;
for (const p of locus()) {
const du = uv[0]-p.u, dv = uv[1]-p.v, d2 = du*du + dv*dv;
if (d2 < bestD2) { bestD2 = d2; best = p; }
}
const dist = Math.sqrt(bestD2);
return { cct: best.cct, duv: uv[1] > best.v ? dist : -dist };
}
function locusXY(cct) { return planckianXY(clampf(cct, 1500, 20000)); }
function lerpCCT(from, to, t) {
const a = 1e6/Math.max(from,1), b = 1e6/Math.max(to,1);
return 1e6/Math.max(a + (b-a)*t, 1e-3);
}
// ---- ZoneStats ----------------------------------------------------------
const GW = 16, GH = 16;
// zones[i] = { linear:[r,g,b], validCount, luma, sampleCount, clippedCount }
function decodeZones(bufA, bufB) {
const zones = [];
for (let i = 0; i < GW*GH; i++) {
const valid = bufA[i*4+3], total = bufB[i*4+1];
if (total <= 0) { zones.push({ linear:[0,0,0], validCount:0, luma:0, sampleCount:0, clippedCount:0 }); continue; }
const lin = valid > 0 ? [bufA[i*4]/valid, bufA[i*4+1]/valid, bufA[i*4+2]/valid] : [0,0,0];
zones.push({ linear:lin, validCount:valid, luma:bufB[i*4]/total,
sampleCount:total, clippedCount:bufB[i*4+2] });
}
return zones;
}
const zAt = (z, x, y) => z[y*GW + x];
const validLuma = z => z.linear[0]*LUMA[0] + z.linear[1]*LUMA[1] + z.linear[2]*LUMA[2];
const clippedFraction = z => z.sampleCount > 0 ? z.clippedCount/z.sampleCount : 0;
// ---- ZoneMetering.meter -------------------------------------------------
const MO = { neutralLo:0.05, neutralHi:0.70, gainLo:0.25, gainHi:4.0, centerSigma:0.55,
outlierStops:1.5, outlierFloor:0.15, backlitRatio:3, backlitSigma:0.12,
backlitFloor:0.10, centerProbe:0.35, surroundProbe:0.60, barLuma:0.005,
maxBarFraction:0.4, barContrastRatio:8, minZoneSpread:0.20,
blackFloorLo:0.16, blackFloorHi:0.24, blackStrength:0.7,
blackPercentile:0.05, clippedCeiling:0.02 };
function meter(zones) {
const rowIsBar = y => { for (let x=0;x<GW;x++){ const z=zAt(zones,x,y); if (!(z.sampleCount===0 || z.luma<=MO.barLuma)) return false; } return true; };
const colIsBar = x => { for (let y=0;y<GH;y++){ const z=zAt(zones,x,y); if (!(z.sampleCount===0 || z.luma<=MO.barLuma)) return false; } return true; };
let top=0, bottom=0, left=0, right=0;
while (top < GH && rowIsBar(top)) top++;
while (bottom < GH-top && rowIsBar(GH-1-bottom)) bottom++;
while (left < GW && colIsBar(left)) left++;
while (right < GW-left && colIsBar(GW-1-right)) right++;
function interiorLuma(t,b,l,r) {
let sum=0, n=0;
for (let y=t;y<GH-b;y++) for (let x=l;x<GW-r;x++) {
const z = zAt(zones,x,y); if (z.sampleCount===0) continue; sum += z.luma; n++;
}
return n>0 ? sum/n : 0;
}
const barFraction = ((top+bottom)*GW + (left+right)*GH) / (GW*GH);
if (barFraction > MO.maxBarFraction || interiorLuma(top,bottom,left,right) < MO.barLuma*MO.barContrastRatio) {
top = bottom = left = right = 0;
}
const lumas = [], coords = [];
for (let y=top;y<GH-bottom;y++) for (let x=left;x<GW-right;x++) {
const z = zAt(zones,x,y); if (z.sampleCount===0) continue;
lumas.push(z.luma); coords.push([x,y]);
}
if (!lumas.length) return null;
const median = lumas.slice().sort((a,b)=>a-b)[lumas.length>>1];
const cx = (GW-1)/2, cy = (GH-1)/2, halfDiag = Math.hypot(cx, cy);
const nd = c => Math.hypot(c[0]-cx, c[1]-cy) / Math.max(halfDiag, 1e-6);
function meanLuma(test) {
let sum=0, n=0;
for (let i=0;i<coords.length;i++) if (test(nd(coords[i]))) { sum += lumas[i]; n++; }
return n>0 ? sum/n : 0;
}
const centerLuma = meanLuma(d => d <= MO.centerProbe);
const surroundLuma = meanLuma(d => d >= MO.surroundProbe);
const backlit = centerLuma > 1e-6 && surroundLuma > centerLuma*MO.backlitRatio;
const sigma = backlit ? MO.backlitSigma : MO.centerSigma;
let weighted=0, wsum=0, used=0;
for (let i=0;i<coords.length;i++) {
const l = lumas[i], d = nd(coords[i]);
let w = Math.exp(-0.5 * Math.pow(d/sigma, 2));
if (median > 1e-6 && l > median) {
const stopsAbove = Math.log2(l/median);
const t = clampf((stopsAbove - MO.outlierStops) / Math.max(MO.outlierStops,1e-6), 0, 1);
w *= 1 - (1 - MO.outlierFloor) * t;
}
if (w <= 1e-6) continue;
weighted += l*w; wsum += w; used++;
}
if (wsum <= 1e-6) return null;
const metered = weighted/wsum;
let clipped=0, total=0;
for (const z of zones) if (z.sampleCount>0) { clipped += z.clippedCount; total += z.sampleCount; }
const clipFrac = total>0 ? clipped/total : 0;
const spreadMean = lumas.reduce((a,b)=>a+b,0)/lumas.length;
const spreadSD = Math.sqrt(lumas.reduce((a,b)=>a+(b-spreadMean)*(b-spreadMean),0)/lumas.length);
const isFlat = spreadMean > 1e-6 && spreadSD/spreadMean < MO.minZoneSpread;
let gain = 1, limited = false;
const floorV = backlit ? Math.max(MO.neutralLo, MO.backlitFloor) : MO.neutralLo;
if (metered > 1e-6 && !isFlat) {
if (metered < floorV) gain = floorV/metered;
else if (metered > MO.neutralHi) gain = MO.neutralHi/metered;
}
if (gain > 1 && clipFrac > MO.clippedCeiling) { gain = 1; limited = true; }
const clampedGain = clampf(gain, MO.gainLo, MO.gainHi);
if (clampedGain !== gain) limited = true;
const targetLuma = Math.min(Math.max(metered*clampedGain, 1e-4), 0.999);
const safeMetered = Math.min(Math.max(metered, 1e-4), 0.999);
let power = 1;
if (Math.abs(clampedGain - 1) > 1e-4)
power = Math.min(Math.max(Math.log(targetLuma)/Math.log(safeMetered), 0.5), 2.0);
const sorted = lumas.slice().sort((a,b)=>a-b);
const idx = Math.min(Math.floor(sorted.length*MO.blackPercentile), sorted.length-1);
const floorEncoded = Math.pow(Math.max(sorted[idx],0), 1/2.4);
let lift = 0, measuredFloor = 0;
if (!isFlat && floorEncoded >= MO.blackFloorLo && floorEncoded <= MO.blackFloorHi) {
measuredFloor = floorEncoded;
const target = floorEncoded * MO.blackStrength;
lift = -target / Math.max(1 - target, 1e-3);
}
return { meteredLuma:metered, gain:clampedGain, exposurePower:power,
bar:[top,bottom,left,right], zonesUsed:used, clippedFraction:clipFrac,
highlightLimited:limited, backlit, isFlat, lift, measuredBlackFloor:measuredFloor };
}
// ---- ZoneAWB ------------------------------------------------------------
const AO = { maxDuv:0.02, cctLo:2000, cctHi:12000, minLuma:0.002, maxClippedFraction:0.25,
zonesForFullConfidence:16, neutralDeadbandDegrees:2.5,
residualDeadbandDegrees:1.0, tightError:0.003, looseError:0.012 };
function awbEstimate(zones) {
const survivors = [];
let considered = 0;
for (const z of zones) {
if (z.sampleCount === 0 || z.validCount === 0) continue;
if (validLuma(z) < AO.minLuma) continue;
if (clippedFraction(z) > AO.maxClippedFraction) continue;
considered++;
const xy = xyFromLinear(z.linear); if (!xy) continue;
const t = temperatureOf(xy);
if (Math.abs(t.duv) > AO.maxDuv || t.cct < AO.cctLo || t.cct > AO.cctHi) continue;
survivors.push({ linear:z.linear, uv:uv1960(xy), weight:z.validCount });
}
if (!survivors.length) return null;
let sum=[0,0,0], uvSum=[0,0], wsum=0;
for (const s of survivors) {
sum[0]+=s.linear[0]*s.weight; sum[1]+=s.linear[1]*s.weight; sum[2]+=s.linear[2]*s.weight;
uvSum[0]+=s.uv[0]*s.weight; uvSum[1]+=s.uv[1]*s.weight; wsum += s.weight;
}
if (wsum <= 0) return null;
const mean = [sum[0]/wsum, sum[1]/wsum, sum[2]/wsum];
const uvMean = [uvSum[0]/wsum, uvSum[1]/wsum];
let varSum = 0;
for (const s of survivors) {
const du = s.uv[0]-uvMean[0], dv = s.uv[1]-uvMean[1];
varSum += (du*du + dv*dv) * s.weight;
}
const spread = Math.sqrt(varSum/wsum);
const stderr = spread / Math.max(Math.sqrt(survivors.length), 1);
const agreement = 1 - clampf((stderr - AO.tightError)/Math.max(AO.looseError-AO.tightError,1e-6), 0, 1);
const coverage = clampf(survivors.length/Math.max(AO.zonesForFullConfidence,1), 0, 1);
const xy = xyFromLinear(mean); if (!xy) return null;
const t = temperatureOf(xy);
return { illuminant:mean, cct:t.cct, duv:t.duv, confidence:agreement*coverage,
zonesUsed:survivors.length, zonesConsidered:considered };
}
function desiredGains(e, neutralization) {
const strength = clampf(neutralization, 0, 1);
const targetCCT = lerpCCT(e.cct, D65_CCT, strength);
const onLocus = locusXY(targetCCT);
const targetXY = [onLocus[0] + (D65_XY[0]-onLocus[0])*strength,
onLocus[1] + (D65_XY[1]-onLocus[1])*strength];
const targetRGB = linearFromXY(targetXY);
if (Math.min(...e.illuminant) <= 1e-5 || Math.min(...targetRGB) <= 1e-5) return null;
return [targetRGB[0]/e.illuminant[0], targetRGB[1]/e.illuminant[1], targetRGB[2]/e.illuminant[2]];
}
function angleBetween(a, b) {
const la = Math.hypot(...a), lb = Math.hypot(...b);
if (la <= 1e-9 || lb <= 1e-9) return 0;
const cos = (a[0]*b[0]+a[1]*b[1]+a[2]*b[2])/(la*lb);
return Math.acos(clampf(cos,-1,1)) * 180/Math.PI;
}
function awbWhiteBalance(e, neutralization) {
const d65 = linearFromXY(D65_XY);
if (angleBetween(e.illuminant, d65) <= AO.neutralDeadbandDegrees) return [0,0];
const desired = desiredGains(e, neutralization);
if (!desired) return [0,0];
const fitted = whiteBalanceMatching(desired);
const c = clampf(e.confidence, 0, 1);
return [fitted[0]*c, fitted[1]*c];
}
function awbCorrection(e, neutralization) {
const wb = awbWhiteBalance(e, neutralization);
if (wb[0] === 0 && wb[1] === 0) return { wb, residual:[1,1,1] };
const desired = desiredGains(e, neutralization);
if (!desired) return { wb, residual:[1,1,1] };
const full = whiteBalanceMatching(desired);
const achieved = normLuma(channelGains(full[0], full[1]));
const want = normLuma(desired);
let residual = [ want[0]/Math.max(achieved[0],1e-4),
want[1]/Math.max(achieved[1],1e-4),
want[2]/Math.max(achieved[2],1e-4) ];
if (angleBetween(residual, [1,1,1]) <= AO.residualDeadbandDegrees) return { wb, residual:[1,1,1] };
const c = clampf(e.confidence, 0, 1);
residual = [Math.pow(residual[0],c), Math.pow(residual[1],c), Math.pow(residual[2],c)];
return { wb, residual: normLuma(residual) };
}
// ---- AutoAdjustEngine ---------------------------------------------------
const TUNE = { cutStops:0.7, powerDeadband:0.03, wbDeadband:0.02, ease:0.08 };
const engine = {
prevLumas:null, wb:[0,0], power:1, lift:0, residual:[1,1,1], started:false,
reset() { this.prevLumas = null; this.wb = [0,0]; this.power = 1; this.lift = 0;
this.residual = [1,1,1]; this.started = false; },
isCut(lumas) {
const prev = this.prevLumas;
if (!prev || prev.length !== lumas.length) return true;
let sum = 0;
for (let i=0;i<lumas.length;i++)
sum += Math.abs(Math.log2(Math.max(lumas[i],1e-4)/Math.max(prev[i],1e-4)));
return sum/lumas.length > TUNE.cutStops;
},
update(zones, neutralization) {
const m = meter(zones); if (!m) return null;
const e = awbEstimate(zones);
const c = e ? awbCorrection(e, neutralization) : null;
const targetWB = c ? c.wb : this.wb;
const targetResidual = c ? c.residual : this.residual;
const lumas = zones.map(z => z.luma);
const cut = this.isCut(lumas);
this.prevLumas = lumas;
const snap = cut || !this.started;
this.started = true;
if (snap) {
this.wb = targetWB; this.power = m.exposurePower;
this.lift = m.lift; this.residual = targetResidual;
} else {
if (Math.abs(m.exposurePower - this.power) > TUNE.powerDeadband)
this.power += (m.exposurePower - this.power) * TUNE.ease;
if (Math.abs(m.lift - this.lift) > TUNE.powerDeadband)
this.lift += (m.lift - this.lift) * TUNE.ease;
const dr = Math.hypot(targetResidual[0]-this.residual[0], targetResidual[1]-this.residual[1], targetResidual[2]-this.residual[2]);
if (dr > TUNE.wbDeadband)
for (let i=0;i<3;i++) this.residual[i] += (targetResidual[i]-this.residual[i]) * TUNE.ease;
if (Math.abs(targetWB[0]-this.wb[0]) > TUNE.wbDeadband || Math.abs(targetWB[1]-this.wb[1]) > TUNE.wbDeadband) {
this.wb = [ this.wb[0] + (targetWB[0]-this.wb[0])*TUNE.ease,
this.wb[1] + (targetWB[1]-this.wb[1])*TUNE.ease ];
}
}
return { metering:m, awb:e, snap };
}
};
// =========================================================================
// WebGL
// =========================================================================
const VS = `#version 300 es
out vec2 vUv;
void main() {
vec2 p = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);
vUv = p;
gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
}`;
// Both sides in one pass. Left of uSplit is the frame as it came, right is
// what Kuro decided — from the same texel of the same frame, which is the
// only way the comparison is honest.
const FS = `#version 300 es
precision highp float;
in vec2 vUv;
out vec4 frag;
uniform sampler2D uTex;
uniform float uSplit;
// auto-adjust — measured per frame
uniform vec3 bWB, bResidual; uniform float bPower, bLift;
// the panel's seven sliders, riding on top of it
uniform vec3 mWB; uniform float mGain, mLift, mGammaExp, mCon, mSat;
uniform float uPivot;
vec3 dec(vec3 v) { return sign(v) * pow(abs(v), vec3(2.4)); }
vec3 enc(vec3 v) { return sign(v) * pow(abs(v), vec3(1.0/2.4)); }
// The free panel, in the app's own order: white balance, then lift / gamma /
// gain, then contrast about a pivot, then luma-preserving saturation.
vec3 manual(vec3 src) {
vec3 c = dec(src) * mWB;
vec3 g = enc(c) * mGain;
g = g + mLift * (vec3(1.0) - g);
g = pow(max(g, vec3(0.0)), vec3(1.0) / max(mGammaExp, 0.001));
c = dec(g);
if (abs(mCon - 1.0) > 0.001) {
float e = pow(max(2.0 - mCon, 1e-3), -1.17);
float pv = clamp(uPivot, 0.01, 0.99);
vec3 x = enc(c);
vec3 below = pow(max(x/pv, 0.0), vec3(e)) * pv;
vec3 above = 1.0 - pow(max((1.0-x)/(1.0-pv), 0.0), vec3(e)) * (1.0-pv);
c = dec(mix(below, above, step(vec3(pv), x)));
}
if (abs(mSat - 1.0) > 0.001) {
vec3 x = enc(clamp(c, 0.0, 1.0));
float y = dot(x, vec3(0.2126, 0.7152, 0.0722));
c = dec(clamp(mix(vec3(y), x, mSat), 0.0, 1.0));
}
return enc(c);
}
// 1.5 sets exposure as a POWER on linear light — the exponent that carries
// the metered luma to its target — not a multiply. No contrast, no
// saturation: the rebuilt auto-adjust does not touch them.
vec3 grade15(vec3 src) {
vec3 c = dec(src) * bWB * bResidual;
c = pow(max(c, vec3(0.0)), vec3(bPower));
vec3 g = enc(c);
g = g + bLift * (vec3(1.0) - g);
return g;
}
void main() {
vec3 src = texture(uTex, vec2(vUv.x, 1.0 - vUv.y)).rgb;
vec3 o = (vUv.x < uSplit) ? src : manual(grade15(src));
frag = vec4(clamp(o, 0.0, 1.0), 1.0);
}`;
// Zone statistics. No compute shaders in WebGL2 and no atomics, so this
// gathers instead of scattering: one fragment per zone, looping over its own
// patch. 16x16 fragments x 12x12 taps is nothing, and it keeps the readback
// to two 16x16 buffers.
const FS_ZONE = `#version 300 es
precision highp float;
uniform sampler2D uTex;
uniform ivec2 uSize;
uniform int uTaps;
layout(location = 0) out vec4 outA; // sumR, sumG, sumB, validCount
layout(location = 1) out vec4 outB; // sumLuma, sampleCount, clippedCount, 0
const float VALID_LO = 0.02, VALID_HI = 0.98, CLIP = 0.996;
void main() {
ivec2 zone = ivec2(gl_FragCoord.xy);
int x0 = uSize.x * zone.x / 16, x1 = uSize.x * (zone.x + 1) / 16;
int y0 = uSize.y * zone.y / 16, y1 = uSize.y * (zone.y + 1) / 16;
vec3 sumRGB = vec3(0.0); float valid = 0.0, sumL = 0.0, n = 0.0, clip = 0.0;
for (int i = 0; i < uTaps; i++) {
for (int j = 0; j < uTaps; j++) {
int px = x0 + (x1 - x0) * i / uTaps;
int py = y0 + (y1 - y0) * j / uTaps;
if (px >= uSize.x || py >= uSize.y || px >= x1 || py >= y1) continue;
vec3 e = clamp(texelFetch(uTex, ivec2(px, py), 0).rgb, 0.0, 1.0);
vec3 lin = pow(e, vec3(2.4));
if (all(greaterThanEqual(e, vec3(VALID_LO))) && all(lessThanEqual(e, vec3(VALID_HI)))) {
sumRGB += lin; valid += 1.0;
}
sumL += dot(lin, vec3(0.2126, 0.7152, 0.0722));
n += 1.0;
if (any(greaterThanEqual(e, vec3(CLIP)))) clip += 1.0;
}
}
outA = vec4(sumRGB, valid);
outB = vec4(sumL, n, clip, 0.0);
}`;
let prog, progZone, tex, fbo, texA, texB, uni = {}, uniZ = {};
function compile(type, src) {
const s = gl.createShader(type);
gl.shaderSource(s, src); gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
return s;
}
function link(vs, fs) {
const p = gl.createProgram();
gl.attachShader(p, compile(gl.VERTEX_SHADER, vs));
gl.attachShader(p, compile(gl.FRAGMENT_SHADER, fs));
gl.linkProgram(p);
if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(p));
return p;
}
function initGL() {
gl = canvas.getContext('webgl2', { antialias:false, alpha:false });
if (!gl) { stage.innerHTML = '<div class="fallback">' + T(STR.noWebGL) + '</div>'; return false; }
if (!gl.getExtension('EXT_color_buffer_float')) {
stage.innerHTML = '<div class="fallback">' + T(STR.noFloat) + '</div>';
return false;
}
prog = link(VS, FS);
progZone = link(VS, FS_ZONE);
gl.useProgram(prog);
['uTex','uSplit','bWB','bResidual','bPower','bLift','mWB','mGain','mLift','mGammaExp','mCon','mSat','uPivot']
.forEach(n => uni[n] = gl.getUniformLocation(prog, n));
gl.uniform1i(uni.uTex, 0);
gl.uniform1f(uni.uPivot, 0.435);
gl.useProgram(progZone);
['uTex','uSize','uTaps'].forEach(n => uniZ[n] = gl.getUniformLocation(progZone, n));
gl.uniform1i(uniZ.uTex, 0);
gl.uniform1i(uniZ.uTaps, 12);
tex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
function mkTarget() {
const t = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, t);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA32F, GW, GH, 0, gl.RGBA, gl.FLOAT, null);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
return t;
}
texA = mkTarget(); texB = mkTarget();
fbo = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texA, 0);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT1, gl.TEXTURE_2D, texB, 0);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
return true;
}
// ---- measurement --------------------------------------------------------
const bufA = new Float32Array(GW*GH*4), bufB = new Float32Array(GW*GH*4);
let lastZones = null, lastResult = null;
function measure() {
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
gl.drawBuffers([gl.COLOR_ATTACHMENT0, gl.COLOR_ATTACHMENT1]);
gl.viewport(0, 0, GW, GH);
gl.useProgram(progZone);
gl.uniform2i(uniZ.uSize, video.videoWidth, video.videoHeight);
gl.drawArrays(gl.TRIANGLES, 0, 3);
gl.readBuffer(gl.COLOR_ATTACHMENT0); gl.readPixels(0,0,GW,GH, gl.RGBA, gl.FLOAT, bufA);
gl.readBuffer(gl.COLOR_ATTACHMENT1); gl.readPixels(0,0,GW,GH, gl.RGBA, gl.FLOAT, bufB);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
lastZones = decodeZones(bufA, bufB);
const out = engine.update(lastZones, parseFloat(document.getElementById('neutral15').value));
if (out) { lastResult = out; showReadout(out); }
}
// ---- readout ------------------------------------------------------------
function cell(label, value, accent) {
return '<div><span style="color:#55554f;">' + label + '</span><br>' +
'<span style="color:' + (accent || '#a4a4a0') + ';">' + value + '</span></div>';
}
function showReadout(out) {
const m = out.metering, e = out.awb;
const g = channelGains(engine.wb[0], engine.wb[1]);
const ja = RW.lang === 'ja';
const flags = [];
if (m.backlit) flags.push(T(RD.backlit));
if (m.isFlat) flags.push(T(RD.flat));
if (m.highlightLimited) flags.push(T(RD.hlLimited));
if (out.snap) flags.push(T(RD.cut));
document.getElementById('readout15').innerHTML =
cell(T(RD.metered), m.meteredLuma.toFixed(4) + ' → ' + (m.meteredLuma*m.gain).toFixed(4)) +
cell(T(RD.exposure), m.exposurePower.toFixed(3)) +
cell(T(RD.black), engine.lift.toFixed(4)) +
cell(T(RD.cct), e ? Math.round(e.cct) + ' K Duv ' + (e.duv>=0?'+':'') + e.duv.toFixed(4) : T(RD.noEstimate)) +
cell(T(RD.confidence), e ? (e.confidence*100).toFixed(0) + '% ' +
(ja ? '(' + e.zonesUsed + '/' + e.zonesConsidered + ' ゾーン)'
: '(' + e.zonesUsed + '/' + e.zonesConsidered + ' zones)') : '—') +
cell(T(RD.tempTint), engine.wb[0].toFixed(3) + ' / ' + engine.wb[1].toFixed(3)) +
cell(T(RD.residual), engine.residual.map(v=>v.toFixed(3)).join(' / ')) +
cell(T(RD.channel), g.map(v=>v.toFixed(3)).join(' / ')) +
cell(T(RD.zones), m.zonesUsed + ' / 256 ' + T(RD.clipped) + (m.clippedFraction*100).toFixed(1) + '%') +
cell(T(RD.state), flags.length ? flags.join(ja ? '・' : ', ') : '—', flags.length ? '#e5484d' : '#a4a4a0');
}
// ---- zone overlay -------------------------------------------------------
const zonesCv = document.getElementById('zonesCv15');
let showZones = false;
function drawZones() {
if (!showZones || !lastZones) return;
const w = zonesCv.width, h = zonesCv.height;
const ctx = zonesCv.getContext('2d');
ctx.clearRect(0,0,w,h);
const cw = w/GW, ch = h/GH;
for (let y=0;y<GH;y++) for (let x=0;x<GW;x++) {
const z = zAt(lastZones,x,y);
ctx.strokeStyle = 'rgba(255,255,255,.12)';
ctx.strokeRect(x*cw, y*ch, cw, ch);
if (z.validCount === 0) continue;
// Fill with the zone's own measured chromaticity at a fixed brightness,
// so the grid shows what the estimator sees rather than the picture.
const l = z.linear, s = Math.max(l[0],l[1],l[2]) || 1;
const r = Math.pow(Math.min(l[0]/s,1), 1/2.4)*255;
const gg = Math.pow(Math.min(l[1]/s,1), 1/2.4)*255;
const b = Math.pow(Math.min(l[2]/s,1), 1/2.4)*255;
ctx.fillStyle = 'rgba(' + (r|0) + ',' + (gg|0) + ',' + (b|0) + ',.55)';
ctx.fillRect(x*cw+cw*0.30, y*ch+ch*0.30, cw*0.40, ch*0.40);
}
}
// ---- render loop --------------------------------------------------------
let sized = false, frame = 0, split = 0.5;
function draw() {
if (!looping) return;
requestAnimationFrame(draw);
if (video.readyState < 2 || !video.videoWidth) return;
if (!sized) {
canvas.width = video.videoWidth; canvas.height = video.videoHeight;
zonesCv.width = video.videoWidth; zonesCv.height = video.videoHeight;
sized = true; stage.classList.add('ready');
}
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, video);
// Metering every 6th frame. The app meters every frame on a GPU it owns;
// here a readback stalls the pipeline, and auto-adjust is a slow control
// loop anyway — it eases at 0.08 per update.
if (frame % 6 === 0) { measure(); drawZones(); }
frame++;
const man = readManual();
gl.useProgram(prog);
gl.uniform1f(uni.uSplit, split);
// With auto off, the identity: the right half is then the manual panel alone.
gl.uniform3fv(uni.bWB, autoOn ? channelGains(engine.wb[0], engine.wb[1]) : [1,1,1]);
gl.uniform3fv(uni.bResidual, autoOn ? engine.residual : [1,1,1]);
gl.uniform1f(uni.bPower, autoOn ? engine.power : 1);
gl.uniform1f(uni.bLift, autoOn ? engine.lift : 0);
gl.uniform3fv(uni.mWB, channelGains(man.temp, man.tint));
gl.uniform1f(uni.mGain, man.gain);
gl.uniform1f(uni.mLift, man.lift);
gl.uniform1f(uni.mGammaExp, man.gammaExp);
gl.uniform1f(uni.mCon, man.con);
gl.uniform1f(uni.mSat, man.sat);