Site icon Filestack Blog

Creating a Mood Journaling App with Filestack Image Sentiment

Overview

This guide demonstrates how to turn a single uploaded photo into a structured mood entry using Filestack’s image sentiment transform. The reference implementation uses the Filestack Picker to upload an image, extracts the file handle, then calls image_sentiment on the handle. The UI displays a preview, emotion bars, a dominant “journal mood,” and the raw JSON for debugging.

The solution highlights production-oriented patterns:

Why mood journaling for image sentiment

Image sentiment is most useful when it becomes a small, reliable unit of data—mood, confidence, timestamp. A journaling UI provides that structure and makes the transform’s output directly actionable for wellness features, timeline summaries, or personalized check-ins.

Of course. Here is the content formatted with h2 headings and code blocks.

API Call (Handle-Based)

Two URL shapes are commonly used, depending on security and transform routing. The demo attempts them in order and treats a 401/403 status as a signal to try the next.

Transform-first with explicit security segment

https://cdn.filestackcontent.com/image_sentiment/security=policy:<POLICY>,signature:<SIGNATURE>/<HANDLE>

API key path with shorthand security

https://cdn.filestackcontent.com/<APIKEY>/security=p:<POLICY>,s:<SIGNATURE>/image_sentiment/<HANDLE>

Response Examples

Successful Response

If emotional content is detected, the API returns an object with confidence scores for each emotion.

{
  "emotions": {
    "HAPPY": 99.7474517822,
    "FEAR": 0.0966,
    "SAD": 0.0441,
    "ANGRY": 0.0396,
    "SURPRISED": 0.0332,
    "DISGUSTED": 0.0135,
    "CONFUSED": 0.0131,
    "CALM": 0.0125
  }
}

Empty Response

If no emotional content is detected, the emotions object will be empty.

{ "emotions": {} }

Mood selection rule

This yields a consistent label while avoiding noise from extremely low confidence scores.

Production notes

Working Demo

This app and image sentiment should fit well in our text sentiment analysis tutorial.

Full reference implementation (index.html)

Single-file, light mode, no frameworks. Uses Filestack v4 picker and a fallback-aware request helper for image_sentiment.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <title>Mood Journal via Image Sentiment</title>
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <script src="https://static.filestackapi.com/filestack-js/4.x.x/filestack.min.js"></script>
  <style>
    :root{ --bg:#f7f9fc; --text:#0f172a; --muted:#475569; --border:#e2e8f0; --card:#ffffff; --accent:#2563eb; }
    *{box-sizing:border-box}
    body{
      margin:0; font-family: ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Inter,Arial;
      background: radial-gradient(1200px 600px at 10% -10%, #e8eefc 0%, transparent 60%),
                  radial-gradient(1000px 500px at 100% 0%, #eaf2ff 0%, transparent 60%), var(--bg);
      color:var(--text); min-height:100vh; display:flex; align-items:stretch; justify-content:center; padding:32px;
    }
    .app{max-width:1120px; width:100%; display:grid; grid-template-columns:380px 1fr; gap:24px}
    .header{grid-column:1/-1; display:flex; align-items:center; justify-content:space-between; gap:12px}
    .title{font-size:20px; font-weight:700}
    .subtitle{color:var(--muted); font-size:13px}
    .panel{background:var(--card); border:1px solid var(--border); border-radius:14px; padding:16px; box-shadow:0 8px 24px rgba(15,23,42,.05)}
    .panel.compact{padding:12px}
    .field{margin:12px 0}
    label{display:block; font-size:12px; color:var(--muted); margin-bottom:6px}
    input[type="text"], input[type="password"], input[type="number"]{
      width:100%; background:#fff; color:var(--text); border:1px solid var(--border); border-radius:10px; padding:10px 12px; outline:none;
    }
    input:focus{border-color:#c7d2fe; box-shadow:0 0 0 3px #e0e7ff}
    .btn{
      display:inline-flex; align-items:center; gap:8px; border:1px solid #c7d2fe; color:#fff;
      background: linear-gradient(180deg,#60a5fa,#2563eb); padding:10px 14px; border-radius:10px; cursor:pointer; font-weight:600;
      box-shadow:0 6px 18px rgba(37,99,235,.25)
    }
    .btn.ghost{background:#fff; color:var(--muted); border:1px solid var(--border); box-shadow:none}
    .row{display:flex; gap:10px; flex-wrap:wrap}
    .preview{display:flex; justify-content:center; align-items:center; margin-bottom:12px}
    .preview img{
      max-width:100%; max-height:260px; border-radius:12px; border:1px solid var(--border); background:#f8fafc; object-fit:cover;
    }
    .kpi{display:flex; align-items:center; gap:10px; background:#f8fafc; border:1px solid var(--border); border-radius:12px; padding:10px 12px}
    .pill{padding:4px 10px; border-radius:999px; font-weight:700; font-size:12px; border:1px solid var(--border); background:#fff}
    .bars{display:grid; gap:10px; margin-top:8px}
    .bar{display:grid; grid-template-columns: 130px 1fr 64px; align-items:center; gap:10px}
    .track{height:10px; background:#f1f5f9; border:1px solid var(--border); border-radius:999px; overflow:hidden}
    .fill{height:100%; background:linear-gradient(90deg,#60a5fa,#2563eb)}
    pre{white-space:pre-wrap; word-break:break-word; background:#f8fafc; border:1px solid var(--border); border-radius:12px; padding:12px; font-size:12px; color:#0f172a}
    .small{font-size:12px; color:var(--muted)}
    .footer{grid-column:1/-1; color:var(--muted); font-size:12px; margin-top:6px}
    @media (max-width:980px){ .app{grid-template-columns:1fr} }
  </style>
</head>
<body>
  <div class="app">
    <header class="header">
      <div>
        <div class="title">Image Sentiment → Mood Journal</div>
        <div class="subtitle">Upload with the Filestack Picker, analyze by handle, and log a mood.</div>
      </div>
      <div class="row">
        <button id="pickerBtn" class="btn">Choose image</button>
        <button id="analyzeBtn" class="btn ghost" title="Run image_sentiment on the selected handle">Analyze</button>
      </div>
    </header>

    <section class="panel">
      <div class="field"><label>API Key</label><input id="apiKey" type="text" placeholder="YOUR_API_KEY" /></div>
      <div class="field"><label>Policy (base64)</label><input id="policy" type="text" placeholder="YOUR_BASE64_POLICY" /></div>
      <div class="field"><label>Signature</label><input id="signature" type="password" placeholder="YOUR_SIGNATURE" /></div>

      <div class="field">
        <label>Selected file handle</label>
        <input id="fileHandle" type="text" placeholder="Populates after upload" readonly />
        <div class="small">The transform is called against this handle.</div>
      </div>

      <div class="row">
        <div class="panel compact" style="flex:1">
          <div class="field">
            <label>Minimum confidence (0–100) to accept a mood</label>
            <input id="minConfidence" type="number" step="1" min="0" max="100" value="10" />
          </div>
        </div>
        <div class="panel compact" style="flex:1">
          <div class="field">
            <label>Fallback mood when none detected</label>
            <input id="fallbackMood" type="text" value="No detectable mood" />
          </div>
        </div>
      </div>

      <div class="row">
        <button class="btn ghost" id="copyUrlBtn">Copy last request URL</button>
        <button class="btn ghost" id="clearBtn">Clear</button>
      </div>
    </section>

    <section class="panel">
      <div class="preview">
        <img id="previewImg" alt="Preview" />
      </div>

      <div class="field">
        <label>Journal mood</label>
        <div class="kpi">
          <div class="pill" id="moodPill">—</div>
          <div id="dominantLabel" class="small">Top: —</div>
        </div>
      </div>

      <div class="bars" id="bars"></div>

      <div class="field">
        <label>Raw JSON</label>
        <pre id="rawJson">{ }</pre>
      </div>
    </section>

    <footer class="footer small">
      In production, sign policies on the server and proxy the transform call to avoid exposing secrets.
    </footer>
  </div>

  <script>
    // ------- Minimal state / refs -------
    let client = null;
    let lastBuiltUrl = '';
    const els = {
      apiKey: document.getElementById('apiKey'),
      policy: document.getElementById('policy'),
      signature: document.getElementById('signature'),
      fileHandle: document.getElementById('fileHandle'),
      pickerBtn: document.getElementById('pickerBtn'),
      analyzeBtn: document.getElementById('analyzeBtn'),
      copyUrlBtn: document.getElementById('copyUrlBtn'),
      clearBtn: document.getElementById('clearBtn'),
      previewImg: document.getElementById('previewImg'),
      moodPill: document.getElementById('moodPill'),
      dominantLabel: document.getElementById('dominantLabel'),
      bars: document.getElementById('bars'),
      rawJson: document.getElementById('rawJson'),
      minConfidence: document.getElementById('minConfidence'),
      fallbackMood: document.getElementById('fallbackMood'),
    };

    // ------- Picker (v3) -------
    function ensureClient() {
      const key = (els.apiKey.value || '').trim();
      const policy = (els.policy.value || '').trim();
      const signature = (els.signature.value || '').trim();
      if (!key || !policy || !signature) throw new Error('Enter API key, policy, and signature first.');
      if (client) return client;
      client = filestack.init(key, { security: { policy, signature } });
      return client;
    }

    function openPicker() {
      try {
        const fs = ensureClient();
        const picker = fs.picker({
          maxFiles: 1,
          accept: ['image/*'],
          fromSources: ['local_file_system','url','webcam','dropbox','googledrive'],
          uploadInBackground: false,
          onUploadDone: (res) => {
            const file = res?.filesUploaded?.[0];
            if (!file) return;
            els.fileHandle.value = file.handle || '';
            // Secured preview (consistent with protected handles)
            els.previewImg.src =
              `https://cdn.filestackcontent.com/resize=width:400,height:300,fit:crop/` +
              `security=policy:${els.policy.value},signature:${els.signature.value}/` +
              `${file.handle}`;
          },
        });
        picker.open();
      } catch (e) {
        alert(e?.message || String(e));
      }
    }

    // ------- URL builders (two shapes) -------
    function buildImageSentimentUrls({ apiKey, policy, signature, handle }) {
      const A = `https://cdn.filestackcontent.com/image_sentiment/security=policy:${policy},signature:${signature}/${handle}`;
      const B = `https://cdn.filestackcontent.com/${apiKey}/security=p:${policy},s:${signature}/image_sentiment/${handle}`;
      return [A, B];
    }

    async function fetchWithFallback(urls) {
      let lastErr = null;
      for (const u of urls) {
        try {
          const res = await fetch(u, { headers: { 'Accept': 'application/json' } });
          if (res.ok) { lastBuiltUrl = u; return res.json(); }
          if (res.status === 401 || res.status === 403) { lastErr = new Error(\`HTTP \${res.status}\`); continue; }
          const txt = await res.text().catch(()=> '');
          lastErr = new Error(\`HTTP \${res.status} – \${res.statusText}\n\${txt}\`);
        } catch (e) {
          lastErr = e;
        }
      }
      throw lastErr || new Error('All URL shapes failed.');
    }

    // ------- Mood engine + UI -------
    const EMOTION_ORDER = ['HAPPY','CALM','SURPRISED','CONFUSED','SAD','ANGRY','FEAR','DISGUSTED'];
    const pct = n => `${(Number(n||0)).toFixed(2)}%`;

    function pickMood(emotions = {}, minConfidence = 10, fallback = 'No detectable mood') {
      let top = { key: null, val: -Infinity };
      for (const [k, v] of Object.entries(emotions)) {
        const score = Number(v) || 0; // 0–100
        if (score > top.val) top = { key: k, val: score };
      }
      if (!top.key || top.val < minConfidence) return { mood: fallback, score: 0 };
      return { mood: top.key, score: top.val };
    }

    function renderBars(emotions = {}) {
      const keys = EMOTION_ORDER.filter(k => k in emotions)
        .concat(Object.keys(emotions).filter(k => !EMOTION_ORDER.includes(k)));
      els.bars.innerHTML = keys.length ? keys.map(k=>{
        const v = Number(emotions[k] || 0);
        return `
          <div class="bar">
            <label>${k}</label>
            <div class="track"><div class="fill" style="width:${Math.max(0, Math.min(100, v))}%"></div></div>
            <div class="small">${pct(v)}</div>
          </div>
        `;
      }).join('') : '<div class="small" style="color:#64748b">No emotions detected.</div>';
    }

    function setMoodUI(mood, score){
      els.moodPill.textContent = mood;
      els.dominantLabel.textContent = (score > 0) ? `Top: ${mood} (${pct(score)})` : 'Top: —';
    }

    // ------- Analyze (fallback-aware) -------
    async function analyze(){
      const handle = (els.fileHandle.value || '').trim();
      if (!handle) { alert('Pick an image first.'); return; }

      const original = els.analyzeBtn.textContent;
      els.analyzeBtn.disabled = true;
      els.analyzeBtn.textContent = 'Analyzing…';

      try {
        const apiKey = (els.apiKey.value||'').trim();
        const policy = (els.policy.value||'').trim();
        const signature = (els.signature.value||'').trim();

        const urls = buildImageSentimentUrls({ apiKey, policy, signature, handle });
        const data = await fetchWithFallback(urls);
        const emotions = data?.emotions || {};

        renderBars(emotions);

        const minC = Number(els.minConfidence.value || 10);
        const fallback = (els.fallbackMood.value || 'No detectable mood').trim();
        const { mood, score } = pickMood(emotions, minC, fallback);
        setMoodUI(mood, score);

        els.rawJson.textContent = JSON.stringify(data, null, 2);
      } catch (e) {
        els.rawJson.textContent = String(e?.message || e);
        renderBars({});
        setMoodUI('—', 0);
      } finally {
        els.analyzeBtn.disabled = false;
        els.analyzeBtn.textContent = original;
      }
    }

    // ------- Events -------
    els.pickerBtn.addEventListener('click', openPicker);
    els.analyzeBtn.addEventListener('click', analyze);
    els.clearBtn.addEventListener('click', ()=>{
      els.fileHandle.value = '';
      els.previewImg.removeAttribute('src');
      els.rawJson.textContent = '{ }';
      renderBars({});
      setMoodUI('—', 0);
      lastBuiltUrl = '';
    });
    els.copyUrlBtn.addEventListener('click', ()=>{
      if (!lastBuiltUrl) return alert('Run Analyze once to generate a request URL.');
      navigator.clipboard.writeText(lastBuiltUrl).then(()=>{
        els.copyUrlBtn.textContent = 'Copied ✓';
        setTimeout(()=> els.copyUrlBtn.textContent = 'Copy last request URL', 900);
      });
    });
  </script>
</body>
</html>

Extensions for the future

Exit mobile version