Creating a Mood Journaling App with Filestack Image Sentiment

Posted on

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:

  • Handle-first pipeline: Picker → Handle → Transform

  • Security segment placement compatible with protected files

  • 403-safe URL construction with a fallback shape

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

  • Pick the highest-scoring emotion ≥ MinConfidence (default 10%).

  • If nothing qualifies or emotions is empty → “No detectable mood.”

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

Production notes

  • Policy & signature: Generate on the server; set short expiries; include the required permissions (typically read and convert).

  • Storage: Persist { handle, mood, score, timestamp } with optional notes or tags.

  • Privacy: Provide visibility and deletion/export paths for end users.

  • Bias & model limits: Treat emotion scores as a heuristic signal; lighting, pose, and culture can affect results.

  • Latency: Show progress during uploads; the transform is fast but asynchronous.

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

  • Calendar & trends: show a monthly heatmap or weekly bar of mood counts.

  • Notes & tags: pair each entry with free-text journaling or activities.

  • Batch import: create entries from an album; filter by confidence thresholds.

  • Custom mapping: collapse multiple emotions into broader categories (e.g., “Positive,” “Neutral,” “Tense”).

Filestack-Banner

Read More →