TipsBlocks Web Technique Library

TB-0005

Film Texture Lab

画像にグレイン、傷、チリ、光漏れ、ちらつき、揺れ、周辺減光などを重ね、トーンやB&Wの深みまでリアルタイムに調整できるフィルム質感ジェネレーターです。手元の画像もその場で読み込めます。

Background Effect Image Effect Visual Effect Image Upload Interactive Control Parameter Control
Interaction
BACKGROUNDから…
Runtime
CSS / Vanilla JavaScript

MOTION PREVIEW

一枚の画像を、古いフィルムの質感へ変える。

粒子や傷だけでなく、チリ、ちらつき、光漏れ、周辺減光、揺れ、色調まで複数の要素を組み合わせ、静止画像の印象をリアルタイムに変化させます。

PREVIEW MEDIA Preview MP4 / Poster を登録すると、ここへ自動表示されます。

LIVE DEMO

背景・色調・フィルム劣化を、その場で調整する。

BACKGROUNDからデモ背景を選ぶか、USE YOUR IMAGEで手元の画像を読み込みます。TONEで色調を選び、B&WではB&W DEPTHで白黒の深みを調整できます。GRAIN、SCRATCH、DUST、FLICKER、LIGHT LEAK、JITTER、VIGNETTE、BLURの各スライダーを動かすと、その場で効果量が変わります。JITTERは0では画像が静止し、値を上げたときだけ映像の揺れが加わります。

HOW TO INTERACT

Image Upload

BACKGROUNDからデモ背景を選ぶか、USE YOUR IMAGEで手元の画像を読み込みます。TONEで色調を選び、B&WではB&W DEPTHで白黒の深みを調整できます。GRAIN、SCRATCH、DUST、FLICKER、LIGHT LEAK、JITTER、VIGNETTE、BLURの各スライダーを動かすと、その場で効果量が変わります。JITTERは0では画像が静止し、値を上げたときだけ映像の揺れが加わります。

ABOUT THIS BLOCK

Film Texture Labとは

Film Texture Labは、通常の画像へ古いフィルムの質感を重ね、ブラウザ上でリアルタイムに調整できるVisual Effectです。

GRAIN、SCRATCH、DUST、FLICKER、LIGHT LEAK、JITTER、VIGNETTE、BLURを個別に調整でき、単なるセピア加工ではなく、粒子、傷、光漏れ、映像の揺れといった複数の要素を組み合わせて質感を作ります。

TONEはNATURAL、WARM、COOL、FADED、TEAL、ROSE、B&W、CUSTOMを用意しています。B&Wは固定プリセットではなくDEPTHスライダーでコントラストと明暗の深さを連続的に変化させることができます。

背景は4種類のデモシーンを切り替えられるほか、USE YOUR IMAGEから手元の画像を読み込み、その場で同じフィルム効果を適用できます。アップロード画像はブラウザ内でFileReaderを使って読み込むため、このRuntime自体はサーバーアップロードを必要としません。

JITTERは初期状態では0に設定し、画像本体は静止します。必要な場合だけスライダーを上げて、古い映写機のようなゲート揺れを追加できる設計です。

Technology
CSS / HTML / JavaScript
Interaction
Image Upload / Interactive Control / Parameter Control
External Library
None
Reduced Motion
Supported

HOW IT WORKS

Film Texture Labはどう質感を作っているのか。

画像本体に色調フィルターを適用し、その上へ複数のCSSレイヤーを重ねています。グレイン、スクラッチ、ダスト、フリッカー、ライトリーク、ビネットを独立したレイヤーとして制御し、JavaScriptはスライダー値をCSS Custom Propertiesへ渡して各効果量をリアルタイムに変更します。

  1. 01

    画像本体とフィルム効果をレイヤー分離する

    背景画像の上へTONE、LIGHT LEAK、FLICKER、GRAIN、SCRATCH、DUST、VIGNETTEの各レイヤーを重ね、効果同士を独立して調整できる構造にしています。

  2. 02

    グレインとスクラッチをCSSパターンで描画する

    repeating-radial-gradientやrepeating-conic-gradientを使って粒子パターンを作り、stepsアニメーションで位置を飛ばしてフィルム粒子の不規則感を出します。スクラッチは細い縦線を複数配置し、それぞれ異なる周期で移動させます。

  3. 03

    チリ・ちらつき・光漏れを別々に加える

    DUSTは短時間だけ現れる小さな粒、FLICKERは明るさの瞬間変化、LIGHT LEAKは画面端から入る暖色系の光として実装しています。別レイヤーにすることで、それぞれの強さを独立して変更できます。

  4. 04

    トーンとB&Wの深みを連続的に制御する

    カラー系プリセットではsepia、saturate、contrast、brightness、hue-rotateなどを組み合わせます。B&WではDEPTH値からコントラストと明るさを連続計算し、柔らかい白黒から深い白黒まで一つのスライダーで変化させます。

  5. 05

    ローカル画像をブラウザ内で読み込む

    USE YOUR IMAGEで選択したファイルはFileReaderでData URLとして読み込みます。Runtime上ではサーバーへ送信せず、その場で背景画像だけを差し替えて同じ効果を確認できます。

  6. 06

    JITTERは必要なときだけ画像本体へ適用する

    初期状態では画像本体を静止させています。JITTERが0より大きくなったときだけgate-weaveアニメーションを有効にし、値に応じて揺れの量と周期を変化させます。

  7. 07

    スライダー値をCSS Custom Propertiesへ渡す

    JavaScriptは各コントロールの値をCSS Custom Propertiesへ反映します。エフェクト描画そのものはCSS側に残すことで、調整項目が増えても各レイヤーの役割を追いやすい構成にしています。

CODE

フィルム質感を構成するレイヤーと調整コードを見る。

HTMLは画像と各エフェクトレイヤー、コントロールUIで構成します。CSSで粒子・傷・チリ・光漏れ・ちらつき・周辺減光・ゲート揺れを描画し、JavaScriptで背景切替、ローカル画像読込、トーン切替、B&W DEPTH、各スライダーを制御します。

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <title>TB-0005 Film Texture V4</title>
  <link rel="stylesheet" href="./style.css">
</head>
<body>
  <main class="stage">
    <div class="scene" aria-label="Film texture preview">
      <img id="sceneImage" src="./assets/scene-country.svg" alt="">
      <div class="film-tone"></div>
      <div class="film-colorwash"></div>
      <div class="film-lightleak left"></div>
      <div class="film-lightleak right"></div>
      <div class="film-flicker"></div>
      <div class="film-grain"></div>
      <div class="film-scratches">
        <i class="scratch s1"></i>
        <i class="scratch s2"></i>
        <i class="scratch s3"></i>
        <i class="scratch s4"></i>
      </div>
      <div class="film-dust">
        <i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i>
      </div>
      <div class="film-vignette"></div>
    </div>

    <section class="control-panel" aria-label="Film texture controls">
      <div class="panel-head">
        <div>
          <span class="eyebrow">FILM TEXTURE</span>
          <h1>OLD FILM LAB</h1>
        </div>
        <button class="reset" id="resetBtn" type="button">RESET</button>
      </div>

      <div class="section-block">
        <div class="section-title">
          <span class="group-label">BACKGROUND</span>
          <span id="uploadName" class="section-value">COUNTRY</span>
        </div>
        <div class="background-grid">
          <button type="button" data-scene="./assets/scene-country.svg" class="scene-choice active"><img src="./assets/scene-country.svg" alt=""><span>COUNTRY</span></button>
          <button type="button" data-scene="./assets/scene-city.svg" class="scene-choice"><img src="./assets/scene-city.svg" alt=""><span>CITY</span></button>
          <button type="button" data-scene="./assets/scene-coast.svg" class="scene-choice"><img src="./assets/scene-coast.svg" alt=""><span>COAST</span></button>
          <button type="button" data-scene="./assets/scene-interior.svg" class="scene-choice"><img src="./assets/scene-interior.svg" alt=""><span>ROOM</span></button>
        </div>
        <label class="upload-button"><input id="imageUpload" type="file" accept="image/*"><span>+ USE YOUR IMAGE</span></label>
      </div>

      <div class="section-block">
        <div class="section-title">
          <span class="group-label">TONE</span>
          <span id="toneName" class="section-value">WARM</span>
        </div>
        <div class="tone-buttons" role="group" aria-label="Tone preset">
          <button type="button" data-tone="natural">NATURAL</button>
          <button type="button" data-tone="warm" class="active">WARM</button>
          <button type="button" data-tone="cool">COOL</button>
          <button type="button" data-tone="faded">FADED</button>
          <button type="button" data-tone="teal">TEAL</button>
          <button type="button" data-tone="rose">ROSE</button>
          <button type="button" data-tone="bw">B&W</button>
          <button type="button" data-tone="custom">CUSTOM</button>
        </div>

        <div class="custom-tone">
          <label><span>COLOR</span><input id="toneColor" type="color" value="#c97943"></label>
          <label class="tone-strength">
            <span><b>TINT</b><output id="tintValue">28</output></span>
            <input id="tint" type="range" min="0" max="100" value="28">
          </label>
        </div>

        <label class="tone-strength bw-depth">
          <span><b>B&W DEPTH</b><output id="bwDepthValue">58</output></span>
          <input id="bwDepth" type="range" min="0" max="100" value="58">
        </label>
      </div>

      <div class="control-list">
        <label class="control"><span><b>GRAIN</b><output id="grainValue">45</output></span><input id="grain" type="range" min="0" max="100" value="45"></label>
        <label class="control"><span><b>SCRATCH</b><output id="scratchValue">55</output></span><input id="scratch" type="range" min="0" max="100" value="55"></label>
        <label class="control"><span><b>DUST</b><output id="dustValue">42</output></span><input id="dust" type="range" min="0" max="100" value="42"></label>
        <label class="control"><span><b>FLICKER</b><output id="flickerValue">35</output></span><input id="flicker" type="range" min="0" max="100" value="35"></label>
        <label class="control"><span><b>LIGHT LEAK</b><output id="leakValue">24</output></span><input id="leak" type="range" min="0" max="100" value="24"></label>
        <label class="control"><span><b>JITTER</b><output id="jitterValue">0</output></span><input id="jitter" type="range" min="0" max="100" value="0"></label>
        <label class="control"><span><b>VIGNETTE</b><output id="vignetteValue">55</output></span><input id="vignette" type="range" min="0" max="100" value="55"></label>
        <label class="control"><span><b>BLUR</b><output id="blurValue">12</output></span><input id="blur" type="range" min="0" max="100" value="12"></label>
      </div>

      <div class="panel-note">
        <span>CSS EFFECT</span>
        <span>IMAGE + TONE + DAMAGE CONTROL</span>
      </div>
    </section>
  </main>

  <script src="./script.js"></script>
</body>
</html>
*{box-sizing:border-box}
:root{
  --bg:#11100d;
  --ink:#14130f;
  --panel:rgba(239,232,216,.94);
  --grain-opacity:.24;
  --scratch-opacity:.72;
  --dust-opacity:.62;
  --flicker-strength:.10;
  --leak-opacity:.14;
  --vignette-strength:.55;
  --scene-blur:.35px;
  --jitter-power:.26;
  --sepia:.48;
  --saturate:.72;
  --contrast:1.08;
  --brightness:.92;
  --grayscale:0;
  --hue:0deg;
  --tint-color:#c97943;
  --tint-opacity:0;
  --tone-overlay:rgba(116,76,28,.06);
}
html,body{margin:0;width:100%;min-height:100%;background:var(--bg)}
body{min-height:100vh;overflow:hidden;font-family:Arial,Helvetica,sans-serif}
button,input{font:inherit}
.stage{position:relative;width:100vw;height:100vh;overflow:hidden;background:#0e0d0b}
.scene{
  position:absolute;inset:0;overflow:hidden;background:#c8b897;
  filter:grayscale(var(--grayscale)) sepia(var(--sepia)) saturate(var(--saturate)) contrast(var(--contrast)) brightness(var(--brightness)) hue-rotate(var(--hue));
}
.scene > img{position:absolute;inset:-1%;width:102%;height:102%;object-fit:cover;filter:blur(var(--scene-blur));transform:translate(0,0) scale(1);animation:none}
.film-tone,.film-colorwash,.film-lightleak,.film-flicker,.film-grain,.film-scratches,.film-dust,.film-vignette{position:absolute;inset:0;pointer-events:none}
.film-tone{z-index:2;background:linear-gradient(180deg,rgba(255,235,195,.06),rgba(40,25,16,.08)),var(--tone-overlay);mix-blend-mode:multiply}
.film-colorwash{z-index:3;background:var(--tint-color);opacity:var(--tint-opacity);mix-blend-mode:color}
.film-lightleak{z-index:4;opacity:var(--leak-opacity);mix-blend-mode:screen;filter:blur(8px);animation:leak-float 5s ease-in-out infinite alternate}
.film-lightleak.left{background:radial-gradient(circle at 8% 50%, rgba(255,196,90,.85) 0, rgba(255,115,70,.55) 22%, rgba(255,80,80,.14) 48%, transparent 68%)}
.film-lightleak.right{background:radial-gradient(circle at 93% 38%, rgba(255,165,120,.72) 0, rgba(255,120,60,.38) 18%, rgba(255,90,40,.10) 42%, transparent 64%);animation-delay:-2.6s}
.film-flicker{z-index:5;background:rgba(255,245,218,.08);mix-blend-mode:screen;opacity:var(--flicker-strength);animation:flicker .22s steps(1,end) infinite}
.film-grain{z-index:6;inset:-22%;width:144%;height:144%;opacity:var(--grain-opacity);mix-blend-mode:multiply;background-image:repeating-radial-gradient(circle at 12% 17%,rgba(0,0,0,.88) 0 1px,transparent 1px 4px),repeating-radial-gradient(circle at 82% 31%,rgba(255,255,255,.55) 0 1px,transparent 1px 5px),repeating-conic-gradient(from 12deg at 50% 50%,rgba(0,0,0,.22) 0 .35deg,transparent .35deg 1.2deg);background-size:7px 9px,11px 13px,17px 19px;animation:grain-jump .31s steps(1,end) infinite}
.film-scratches{z-index:7;opacity:var(--scratch-opacity);filter:blur(.15px)}
.scratch{position:absolute;top:-10%;width:1px;height:120%;background:linear-gradient(to bottom,transparent 0 3%,rgba(255,255,255,.72) 12%,rgba(255,255,255,.18) 56%,rgba(255,255,255,.60) 82%,transparent 100%);box-shadow:1px 0 rgba(0,0,0,.30),-1px 0 rgba(255,255,255,.12);transform-origin:center}
.s1{left:17%;animation:scratch-a 1.35s steps(1,end) infinite}
.s2{left:43%;opacity:.42;animation:scratch-b 2.05s steps(1,end) infinite}
.s3{left:68%;opacity:.57;animation:scratch-c 1.65s steps(1,end) infinite}
.s4{left:87%;opacity:.30;animation:scratch-d 2.6s steps(1,end) infinite}
.film-dust{z-index:8;opacity:var(--dust-opacity)}
.film-dust i{position:absolute;width:6px;height:6px;border-radius:50%;background:rgba(255,245,220,.72);box-shadow:0 0 2px rgba(0,0,0,.35);opacity:0}
.film-dust i:nth-child(1){left:14%;top:24%;animation:dust 1.8s .1s steps(1,end) infinite}
.film-dust i:nth-child(2){left:31%;top:69%;animation:dust 2.2s .7s steps(1,end) infinite}
.film-dust i:nth-child(3){left:52%;top:35%;animation:dust 1.6s 1.1s steps(1,end) infinite}
.film-dust i:nth-child(4){left:72%;top:58%;animation:dust 2.4s .3s steps(1,end) infinite}
.film-dust i:nth-child(5){left:86%;top:18%;animation:dust 2.0s 1.4s steps(1,end) infinite}
.film-dust i:nth-child(6){left:61%;top:80%;animation:dust 2.8s .9s steps(1,end) infinite}
.film-dust i:nth-child(7){left:8%;top:61%;animation:dust 2.5s .35s steps(1,end) infinite}
.film-dust i:nth-child(8){left:92%;top:73%;animation:dust 3s 1.8s steps(1,end) infinite}
.film-vignette{z-index:9;box-shadow:inset 0 0 calc(5vw + 12vw * var(--vignette-strength)) rgba(0,0,0,calc(.18 + .48 * var(--vignette-strength))),inset 0 0 calc(1vw + 4vw * var(--vignette-strength)) rgba(0,0,0,calc(.12 + .38 * var(--vignette-strength)));background:radial-gradient(ellipse at center,transparent 44%,rgba(0,0,0,calc(.03 + .12 * var(--vignette-strength))) 68%,rgba(0,0,0,calc(.12 + .52 * var(--vignette-strength))) 100%)}
.control-panel{position:absolute;z-index:20;top:20px;right:20px;bottom:20px;width:min(380px,calc(100vw - 40px));overflow:auto;padding:20px 20px 16px;color:var(--ink);background:var(--panel);border:1px solid rgba(255,255,255,.50);box-shadow:0 22px 70px rgba(0,0,0,.24);backdrop-filter:blur(14px)}
.panel-head{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:18px}
.eyebrow,.group-label{display:block;margin-bottom:5px;font-size:8px;font-weight:800;letter-spacing:.18em;opacity:.52}
h1{margin:0;font-size:24px;line-height:1;letter-spacing:-.05em}
.reset{padding:7px 9px;color:inherit;background:transparent;border:1px solid rgba(20,19,15,.22);cursor:pointer;font-size:8px;font-weight:800;letter-spacing:.12em}
.section-block{margin-bottom:20px;padding-bottom:18px;border-bottom:1px solid rgba(20,19,15,.12)}
.section-title{display:flex;justify-content:space-between;align-items:center;margin-bottom:9px}
.section-title .group-label{margin:0}
.section-value{font-size:7px;font-weight:800;letter-spacing:.10em;opacity:.48;max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.background-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:5px}
.scene-choice{position:relative;aspect-ratio:1.3;overflow:hidden;padding:0;border:2px solid transparent;background:#222;cursor:pointer}
.scene-choice img{width:100%;height:100%;object-fit:cover;display:block}
.scene-choice span{position:absolute;left:3px;bottom:3px;padding:2px 4px;color:#fff;background:rgba(0,0,0,.48);font-size:6px;font-weight:800;letter-spacing:.08em}
.scene-choice.active{border-color:#171612}
.upload-button{display:flex;align-items:center;justify-content:center;margin-top:7px;min-height:32px;border:1px dashed rgba(20,19,15,.30);cursor:pointer;font-size:8px;font-weight:800;letter-spacing:.10em}
.upload-button:hover{background:rgba(20,19,15,.05)}
.upload-button input{display:none}
.tone-buttons{display:grid;grid-template-columns:repeat(4,1fr);border-top:1px solid rgba(20,19,15,.18);border-left:1px solid rgba(20,19,15,.18)}
.tone-buttons button{min-height:34px;padding:6px 4px;color:inherit;background:transparent;border:0;border-right:1px solid rgba(20,19,15,.18);border-bottom:1px solid rgba(20,19,15,.18);cursor:pointer;font-size:7px;font-weight:800;letter-spacing:.05em}
.tone-buttons button.active{color:#f3edde;background:#171612}
.custom-tone{display:grid;grid-template-columns:64px 1fr;gap:12px;align-items:end;margin-top:11px}
.custom-tone > label:first-child{display:grid;gap:6px;font-size:7px;font-weight:800;letter-spacing:.08em}
input[type="color"]{width:100%;height:26px;padding:0;border:1px solid rgba(20,19,15,.22);background:transparent;cursor:pointer}
.tone-strength,.control{display:grid;gap:8px}
.tone-strength > span,.control > span{display:flex;align-items:center;justify-content:space-between;font-size:8px;letter-spacing:.08em}
.bw-depth{margin-top:12px}
.control-list{display:grid;grid-template-columns:1fr 1fr;gap:15px 18px}
.control b,.tone-strength b{font-size:8px}
.control output,.tone-strength output{min-width:28px;text-align:right;font-size:8px;opacity:.58}
input[type="range"]{width:100%;height:14px;margin:0;appearance:none;background:transparent;cursor:pointer}
input[type="range"]::-webkit-slider-runnable-track{height:2px;background:rgba(20,19,15,.22)}
input[type="range"]::-webkit-slider-thumb{width:14px;height:14px;margin-top:-6px;appearance:none;border:0;border-radius:50%;background:#171612}
input[type="range"]::-moz-range-track{height:2px;background:rgba(20,19,15,.22)}
input[type="range"]::-moz-range-thumb{width:14px;height:14px;border:0;border-radius:50%;background:#171612}
.panel-note{display:flex;justify-content:space-between;margin-top:18px;padding-top:12px;border-top:1px solid rgba(20,19,15,.13);font-size:7px;font-weight:800;letter-spacing:.13em;opacity:.44}
@keyframes gate-weave{
  0%,100%{transform:translate(0,0) scale(calc(1.004 + var(--jitter-power)*.008))}
  12%{transform:translate(calc(-.12% - var(--jitter-power)*.55%),calc(.06% + var(--jitter-power)*.3%)) scale(calc(1.004 + var(--jitter-power)*.008))}
  26%{transform:translate(calc(.08% + var(--jitter-power)*.4%),calc(-.10% - var(--jitter-power)*.42%)) scale(calc(1.004 + var(--jitter-power)*.008))}
  42%{transform:translate(calc(-.05% - var(--jitter-power)*.24%),calc(.10% + var(--jitter-power)*.46%)) scale(calc(1.004 + var(--jitter-power)*.008))}
  61%{transform:translate(calc(.11% + var(--jitter-power)*.5%),calc(.03% + var(--jitter-power)*.18%)) scale(calc(1.004 + var(--jitter-power)*.008))}
  78%{transform:translate(calc(-.08% - var(--jitter-power)*.4%),calc(-.05% - var(--jitter-power)*.25%)) scale(calc(1.004 + var(--jitter-power)*.008))}
}
@keyframes leak-float{from{transform:translateX(-2%) scale(1)}to{transform:translateX(2%) scale(1.05)}}
@keyframes flicker{0%,100%{filter:brightness(.94)}8%{filter:brightness(1.20)}18%{filter:brightness(.82)}31%{filter:brightness(1.10)}49%{filter:brightness(.91)}64%{filter:brightness(1.24)}79%{filter:brightness(.86)}91%{filter:brightness(1.08)}}
@keyframes grain-jump{0%,100%{transform:translate(0,0) rotate(0deg)}12%{transform:translate(-3%,2%) rotate(.15deg)}24%{transform:translate(2%,-2%) rotate(-.1deg)}37%{transform:translate(-1%,4%) rotate(.08deg)}51%{transform:translate(4%,-3%) rotate(-.12deg)}66%{transform:translate(-4%,-1%) rotate(.10deg)}82%{transform:translate(3%,3%) rotate(-.08deg)}}
@keyframes scratch-a{0%,100%{transform:translateX(0);opacity:.72}18%{transform:translateX(2vw);opacity:.25}35%{transform:translateX(-1vw);opacity:.85}52%{transform:translateX(5vw);opacity:.20}77%{transform:translateX(-3vw);opacity:.55}}
@keyframes scratch-b{0%,100%{transform:translateX(0) scaleY(.82)}22%{transform:translateX(-4vw) scaleY(1.04)}54%{transform:translateX(7vw) scaleY(.70)}79%{transform:translateX(1vw) scaleY(.95)}}
@keyframes scratch-c{0%,100%{transform:translateX(0);opacity:.15}17%{transform:translateX(-2vw);opacity:.70}47%{transform:translateX(4vw);opacity:.32}68%{transform:translateX(-5vw);opacity:.62}88%{transform:translateX(3vw);opacity:.18}}
@keyframes scratch-d{0%,100%{transform:translateX(0) scaleY(.55)}30%{transform:translateX(-7vw) scaleY(.90)}63%{transform:translateX(2vw) scaleY(.66)}84%{transform:translateX(-3vw) scaleY(1.05)}}
@keyframes dust{0%,18%,100%{opacity:0;transform:translate(0,0) scale(.4)}20%{opacity:.65;transform:translate(0,0) scale(1)}23%{opacity:.18;transform:translate(3px,-5px) scale(.6)}26%{opacity:0;transform:translate(-2px,3px) scale(.35)}}
@media (max-width:760px){
  .control-panel{top:auto;right:12px;bottom:12px;left:12px;width:auto;max-height:62vh}
  h1{font-size:19px}
  .background-grid{grid-template-columns:repeat(4,1fr)}
  .control-list{grid-template-columns:1fr 1fr}
}
(() => {
  const root = document.documentElement;
  const sceneImage = document.getElementById("sceneImage");
  const imageUpload = document.getElementById("imageUpload");
  const uploadName = document.getElementById("uploadName");
  const toneName = document.getElementById("toneName");

  const controls = {
    grain: document.getElementById("grain"),
    scratch: document.getElementById("scratch"),
    dust: document.getElementById("dust"),
    flicker: document.getElementById("flicker"),
    leak: document.getElementById("leak"),
    jitter: document.getElementById("jitter"),
    vignette: document.getElementById("vignette"),
    blur: document.getElementById("blur"),
    tint: document.getElementById("tint"),
    bwDepth: document.getElementById("bwDepth")
  };

  const outputs = {
    grain: document.getElementById("grainValue"),
    scratch: document.getElementById("scratchValue"),
    dust: document.getElementById("dustValue"),
    flicker: document.getElementById("flickerValue"),
    leak: document.getElementById("leakValue"),
    jitter: document.getElementById("jitterValue"),
    vignette: document.getElementById("vignetteValue"),
    blur: document.getElementById("blurValue"),
    tint: document.getElementById("tintValue"),
    bwDepth: document.getElementById("bwDepthValue")
  };

  const toneColor = document.getElementById("toneColor");
  const resetBtn = document.getElementById("resetBtn");
  const toneButtons = [...document.querySelectorAll("[data-tone]")];
  const sceneButtons = [...document.querySelectorAll("[data-scene]")];

  let activeTone = "warm";

  const defaults = {
    grain:45, scratch:55, dust:42, flicker:35, leak:24, jitter:0, vignette:55, blur:12, tint:28, bwDepth:58,
    tone:"warm", color:"#c97943", scene:"./assets/scene-country.svg"
  };

  const tones = {
    natural:{sepia:.06,saturate:.92,contrast:1.03,brightness:.98,grayscale:0,hue:"0deg",overlay:"rgba(40,30,20,.025)",tint:0,label:"NATURAL"},
    warm:{sepia:.46,saturate:.72,contrast:1.08,brightness:.92,grayscale:0,hue:"0deg",overlay:"rgba(116,76,28,.06)",tint:.05,label:"WARM"},
    cool:{sepia:.05,saturate:.70,contrast:1.08,brightness:.93,grayscale:0,hue:"-16deg",overlay:"rgba(45,80,105,.08)",tint:.08,label:"COOL"},
    faded:{sepia:.20,saturate:.48,contrast:.92,brightness:1.02,grayscale:0,hue:"4deg",overlay:"rgba(215,186,140,.10)",tint:.05,label:"FADED"},
    teal:{sepia:.08,saturate:.78,contrast:1.06,brightness:.92,grayscale:0,hue:"-26deg",overlay:"rgba(22,90,96,.12)",tint:.11,label:"TEAL"},
    rose:{sepia:.18,saturate:.82,contrast:1.02,brightness:.94,grayscale:0,hue:"18deg",overlay:"rgba(124,54,58,.10)",tint:.10,label:"ROSE"},
    bw:{label:"B&W"},
    custom:{sepia:.05,saturate:.82,contrast:1.06,brightness:.95,grayscale:0,hue:"0deg",overlay:"rgba(0,0,0,0)",tint:null,label:"CUSTOM"}
  };

  function applyBwDepth(){
    const d = +controls.bwDepth.value / 100;
    root.style.setProperty("--grayscale", 1);
    root.style.setProperty("--sepia", 0);
    root.style.setProperty("--saturate", 0);
    root.style.setProperty("--hue", "0deg");
    root.style.setProperty("--contrast", (0.94 + d * 0.88).toFixed(3));
    root.style.setProperty("--brightness", (1.05 - d * 0.26).toFixed(3));
    root.style.setProperty("--tone-overlay", `rgba(0,0,0,${(0.01 + d * 0.10).toFixed(3)})`);
    root.style.setProperty("--tint-opacity", "0");
  }

  function updateJitter(){
    const jitter = +controls.jitter.value;
    const power = jitter / 100;
    root.style.setProperty("--jitter-power", power.toFixed(3));

    if (jitter <= 0) {
      sceneImage.style.animation = "none";
      sceneImage.style.transform = "translate(0,0) scale(1)";
    } else {
      const duration = Math.max(0.55, 1.9 - power * 1.1).toFixed(2);
      sceneImage.style.animation = `gate-weave ${duration}s steps(1,end) infinite`;
    }
  }

  function updateEffects(){
    const grain = +controls.grain.value;
    const scratch = +controls.scratch.value;
    const dust = +controls.dust.value;
    const flicker = +controls.flicker.value;
    const leak = +controls.leak.value;
    const vignette = +controls.vignette.value;
    const blur = +controls.blur.value;
    const tint = +controls.tint.value;

    root.style.setProperty("--grain-opacity",(grain/100*.54).toFixed(3));
    root.style.setProperty("--scratch-opacity",(scratch/100).toFixed(3));
    root.style.setProperty("--dust-opacity",(dust/100).toFixed(3));
    root.style.setProperty("--flicker-strength",(flicker/100*.46).toFixed(3));
    root.style.setProperty("--leak-opacity",(leak/100*.58).toFixed(3));
    root.style.setProperty("--vignette-strength",(vignette/100).toFixed(3));
    root.style.setProperty("--scene-blur",`${(blur/100*2.8).toFixed(2)}px`);

    updateJitter();

    if(activeTone === "bw"){
      applyBwDepth();
    }else{
      const preset = tones[activeTone];
      const tintOpacity = activeTone === "custom" ? tint/100*.42 : (preset.tint || 0);
      root.style.setProperty("--tint-opacity",tintOpacity.toFixed(3));
    }

    Object.entries(outputs).forEach(([key,out]) => out.textContent = controls[key].value);
  }

  function setTone(name){
    activeTone = name;

    if(name === "bw"){
      toneButtons.forEach(button => button.classList.toggle("active", button.dataset.tone === name));
      toneName.textContent = "B&W";
      applyBwDepth();
      updateEffects();
      return;
    }

    const tone = tones[name] || tones.warm;
    root.style.setProperty("--sepia", tone.sepia);
    root.style.setProperty("--saturate", tone.saturate);
    root.style.setProperty("--contrast", tone.contrast);
    root.style.setProperty("--brightness", tone.brightness);
    root.style.setProperty("--grayscale", tone.grayscale || 0);
    root.style.setProperty("--hue", tone.hue || "0deg");
    root.style.setProperty("--tone-overlay", tone.overlay);

    toneButtons.forEach(button => button.classList.toggle("active", button.dataset.tone === name));
    toneName.textContent = tone.label;
    updateEffects();
  }

  function setScene(src, label, button=null){
    sceneImage.src = src;
    uploadName.textContent = label;
    sceneButtons.forEach(b => b.classList.toggle("active", b === button));
    updateJitter();
  }

  sceneButtons.forEach(button => {
    button.addEventListener("click", () => {
      setScene(button.dataset.scene, button.querySelector("span").textContent, button);
    });
  });

  imageUpload.addEventListener("change", () => {
    const file = imageUpload.files && imageUpload.files[0];
    if(!file) return;
    const reader = new FileReader();
    reader.addEventListener("load", () => setScene(reader.result, file.name, null));
    reader.readAsDataURL(file);
  });

  toneButtons.forEach(button => button.addEventListener("click", () => setTone(button.dataset.tone)));

  toneColor.addEventListener("input", () => {
    root.style.setProperty("--tint-color", toneColor.value);
    if(activeTone !== "custom") setTone("custom");
    updateEffects();
  });

  Object.values(controls).forEach(control => control.addEventListener("input", updateEffects));

  resetBtn.addEventListener("click", () => {
    Object.entries(defaults).forEach(([key, value]) => { if(controls[key]) controls[key].value = value; });
    toneColor.value = defaults.color;
    root.style.setProperty("--tint-color", defaults.color);
    setScene(defaults.scene, "COUNTRY", sceneButtons[0]);
    setTone(defaults.tone);
    updateEffects();
  });

  root.style.setProperty("--tint-color", toneColor.value);
  setTone(defaults.tone);
  updateEffects();
})();

USE CASE

フィルム質感をWebデザインへどう活用できるか。

写真を使ったヒーロー、ポートフォリオ、音楽・映画・ファッション系サイト、イベントページ、ストーリー性のあるビジュアルなど、通常の画像へ時間感覚や質感を加えたい場面に利用できます。

Photo Hero / Key Visual

Photo Hero / Key Visual

写真をそのまま表示するのではなく、古い映画や記録映像のような質感を加え、サイトのメインビジュアルへ時間感覚や物語性を持たせる用途に向いています。

Music / Film / Fashion

Music / Film / Fashion

音楽、映画、ファッション、カルチャー系のページで、写真へレトロ・シネマティックな質感を追加し、ブランドの世界観を強める表現として使えます。

Portfolio / Story Section

Portfolio / Story Section

作品写真や過去の記録、ストーリーセクションなどで、通常の画像とは異なる質感へ切り替え、場面転換や時間の経過を視覚的に示す用途にも利用できます。

TECHNICAL NOTES

実装前に確認しておきたいこと。

複数のmix-blend-mode、filter、CSSアニメーションを重ねています。高解像度画像や大量の同時表示では描画負荷が増えるため、実サイトでは表示サイズやアニメーション量を調整してください。JITTERは必要な場合だけ有効にする設計です。

Browser

CSS Custom Properties、filter、mix-blend-mode、FileReader APIを使用します。現行の主要ブラウザを対象とし、公開前に実際の対象環境で質感と描画結果を確認してください。

Performance

CanvasやWebGLを使わず、DOM/CSSベースで複数レイヤーを重ねています。グレイン、ブラー、mix-blend-mode、アニメーションの組み合わせはGPU描画負荷が増えることがあるため、実装先では画像サイズと同時表示数を確認してください。

Mobile

モバイルではコントロールパネルを画面下部へ配置します。ローカル画像読込も利用できますが、大きな画像は端末メモリを消費するため、実サービスへ組み込む場合は画像サイズ制限や縮小処理を検討してください。

Reduced Motion

実サイトではprefers-reduced-motionに応じてFLICKER、LIGHT LEAK、JITTER、GRAINなどの時間変化を弱める、または停止する対応を推奨します。