video understanding
Some checks failed
Build and Deploy / build (push) Failing after 2m17s
Build and Deploy / docker-build (push) Has been skipped
Deploy to Server / deploy (push) Successful in 43s

This commit is contained in:
2026-04-29 11:50:44 -05:00
parent 5bf0622c38
commit 193a825899
14 changed files with 1435 additions and 15 deletions

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
node_modules/
dist/
.env
recipes/
temp_ingest/
implementation_plan.md
.gitignore
.gitea/
*.md

View File

@@ -0,0 +1,34 @@
name: Build and Deploy
on:
push:
branches:
- recipeGen
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Build the app
run: npm run build
docker-build:
runs-on: ubuntu-latest
needs: build
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Build Docker image
run: docker build -t recipe-generator:latest .

View File

@@ -1,9 +1,14 @@
FROM node:20-alpine
WORKDIR /app
# Install python3 and yt-dlp (no ffmpeg to save space)
RUN apk add --no-cache python3 py3-pip && \
pip3 install --break-system-packages yt-dlp
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
EXPOSE 80
EXPOSE 3000
CMD ["node", "server.js"]

48
dist/assets/index-CrgoFxjd.js vendored Normal file
View File

@@ -0,0 +1,48 @@
(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const i of document.querySelectorAll('link[rel="modulepreload"]'))s(i);new MutationObserver(i=>{for(const o of i)if(o.type==="childList")for(const l of o.addedNodes)l.tagName==="LINK"&&l.rel==="modulepreload"&&s(l)}).observe(document,{childList:!0,subtree:!0});function n(i){const o={};return i.integrity&&(o.integrity=i.integrity),i.referrerPolicy&&(o.referrerPolicy=i.referrerPolicy),i.crossOrigin==="use-credentials"?o.credentials="include":i.crossOrigin==="anonymous"?o.credentials="omit":o.credentials="same-origin",o}function s(i){if(i.ep)return;i.ep=!0;const o=n(i);fetch(i.href,o)}})();let g="",y="";const B=document.getElementById("login-btn"),$=document.getElementById("login-overlay"),S=document.getElementById("app-content");B.addEventListener("click",async()=>{const e=document.getElementById("login-username").value,t=document.getElementById("login-password").value,n=document.getElementById("login-btn");n.disabled=!0,n.innerText="Verifying...";try{(await fetch("/api/login",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:e,password:t})})).ok?(g=e,y=t,$.style.display="none",S.style.display="block"):document.getElementById("login-error").style.display="block"}catch{document.getElementById("login-error").innerText="Network error. Please try again.",document.getElementById("login-error").style.display="block"}n.disabled=!1,n.innerText="Sign in"});const r=[],v=document.getElementById("ing-wrapper"),c=document.getElementById("ing-input"),P=document.getElementById("gen-btn");P.addEventListener("click",C);v.addEventListener("click",()=>c.focus());c.addEventListener("keydown",e=>{(e.key==="Enter"||e.key===",")&&c.value.trim()?(e.preventDefault(),b(c.value.trim().replace(/,$/,"")),c.value=""):e.key==="Backspace"&&!c.value&&r.length&&O(r.length-1)});function b(e){if(!e||r.includes(e.toLowerCase()))return;r.push(e.toLowerCase());const t=document.createElement("div");t.className="tag",t.innerHTML=e+'<span class="tag-x">×</span>',t.querySelector(".tag-x").addEventListener("click",n=>{n.stopPropagation(),M(e.toLowerCase())}),v.insertBefore(t,c)}function M(e){const t=r.indexOf(e);t>-1&&(r.splice(t,1),I())}function O(e){r.splice(e,1),I()}function I(){v.querySelectorAll(".tag").forEach(t=>t.remove());const e=[...r];r.length=0,e.forEach(t=>b(t))}document.querySelectorAll(".check-pill").forEach(e=>{e.addEventListener("click",()=>{const t=e.querySelector("input");t.checked=!t.checked,e.classList.toggle("checked",t.checked)})});async function C(){const e=document.getElementById("goal").value.trim();if(!e&&r.length===0){E("Please add some ingredients or describe what you want to make.");return}const t=[...document.querySelectorAll(".check-pill input:checked")].map(a=>a.value),n=document.getElementById("cuisine").value,s=document.getElementById("time").value,i=document.getElementById("skill").value,o=document.getElementById("servings").value,l=document.getElementById("notes").value.trim(),d=document.getElementById("gen-btn");d.disabled=!0,d.innerHTML='<span class="spinner"></span>Generating…',document.getElementById("error-area").innerHTML="",document.getElementById("recipe-area").innerHTML='<div class="status">Crafting your recipe with care…</div>';const L=[r.length?`Ingredients available: ${r.join(", ")}`:"",e?`Food goal: ${e}`:"",t.length?`Dietary restrictions: ${t.join(", ")}`:"",n?`Preferred cuisine: ${n}`:"",s?`Time constraint: ${s}`:"",i?`Skill level: ${i}`:"",`Servings: ${o}`,l?`Additional preferences: ${l}`:""].filter(Boolean).join(`
`),T=`You are an expert chef and culinary writer. Create a complete, precise recipe based on the user's inputs.
CRITICAL: Instructions must be specific — never vague. Include exact temperatures, times, visual cues, sounds, smells, and textures. Example: instead of "cook the onions", write "cook the onions in the butter over medium heat, stirring occasionally, for 810 minutes until they turn translucent and just begin to turn golden at the edges."
Respond with ONLY a valid JSON object. Use this exact structure:
{"title":"string","description":"string","servings":2,"prepTime":"string","cookTime":"string","ingredients":[{"amount":"string","unit":"string","name":"string"}],"steps":[{"title":"string","instruction":"string"}],"notes":"string or null"}
Rules: use provided ingredients as the base, add pantry staples as needed, 510 steps, each instruction 13 richly detailed sentences, respect all dietary restrictions strictly.`;try{const a=await fetch("/api/generate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:g,password:y,systemPrompt:T,userPrompt:L})}),w=await a.json();if(!a.ok)throw new Error(w.error||`Server error ${a.status}`);const k=w.candidates[0].content.parts[0].text,x=JSON.parse(k);f(x)}catch(a){document.getElementById("recipe-area").innerHTML="",E("Error: "+(a.message||"Unknown error. Please try again."))}d.disabled=!1,d.innerHTML="Generate my recipe"}function E(e){document.getElementById("error-area").innerHTML=`<div class="error-msg">${e}</div>`}let u=null,m=null;const h=document.getElementById("saved-menu"),p=document.getElementById("saved-list");document.getElementById("open-menu-btn").addEventListener("click",async()=>{h.classList.add("open"),await R()});document.getElementById("close-menu-btn").addEventListener("click",()=>{h.classList.remove("open")});async function R(){p.innerHTML='<p style="font-size: 13px; color: var(--color-text-secondary); text-align: center;">Loading...</p>';try{const e=await fetch("/api/recipes",{headers:{"x-username":g,"x-password":y}});if(!e.ok)throw new Error("Failed to fetch");const t=await e.json();if(t.length===0){p.innerHTML='<p style="font-size: 13px; color: var(--color-text-secondary); text-align: center;">No saved recipes yet.</p>';return}p.innerHTML=t.reverse().map(n=>`
<div class="saved-recipe-item" onclick="loadSavedRecipe('${n.id}')">
<h3>${n.title}</h3>
<p>${n.description}</p>
</div>
`).join("")}catch{p.innerHTML='<p style="font-size: 13px; color: var(--color-text-danger); text-align: center;">Error loading recipes.</p>'}}window.loadSavedRecipe=async function(e){h.classList.remove("open");try{const t=await fetch(`/api/recipes/${e}`);if(!t.ok)throw new Error("Failed to fetch recipe");const n=await t.json();n.id=e,f(n),document.getElementById("recipe-area").scrollIntoView({behavior:"smooth"})}catch(t){console.error(t)}};window.saveCurrentRecipe=async function(){if(!u)return;const e=document.getElementById("save-recipe-btn"),t=e.innerText;e.innerText="Saving...",e.disabled=!0;try{const n=await fetch("/api/recipes",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:g,password:y,recipe:u})});if(n.ok){const s=await n.json();m=s.id,u.id=s.id,e.innerText="Saved!"}else throw new Error("Save failed")}catch{e.innerText="Error Saving",setTimeout(()=>{e.innerText=t,e.disabled=!1},2e3)}};window.shareRecipeLink=function(){if(!m){alert("Please save the recipe first to share it!");return}const e=`${window.location.origin}${window.location.pathname}#shared=${m}`;navigator.clipboard.writeText(e).then(()=>{const t=document.getElementById("share-recipe-btn"),n=t.innerText;t.innerText="Link Copied!",setTimeout(()=>t.innerText=n,2e3)})};function f(e,t=!1){u=e,m=e.id||null;const n=(e.ingredients||[]).map(i=>`<li><span class="ing-amount">${i.amount}${i.unit?" "+i.unit:""}</span><span class="ing-name">${i.name}</span></li>`).join(""),s=(e.steps||[]).map((i,o)=>`<li class="step-item">
<span class="step-num">${o+1}</span>
<div>
<div class="step-title">${i.title}</div>
<div class="step-instr">${i.instruction}</div>
</div>
</li>`).join("");document.getElementById("recipe-area").innerHTML=`
<div class="recipe-out">
<div class="recipe-title">${e.title}</div>
<div class="recipe-desc">${e.description}</div>
<div class="meta-row">
<div class="meta-chip"><strong>${e.servings}</strong> servings</div>
<div class="meta-chip">Prep <strong>${e.prepTime}</strong></div>
<div class="meta-chip">Cook <strong>${e.cookTime}</strong></div>
</div>
<div class="section-label">Ingredients</div>
<ul class="ingredients-list">${n}</ul>
<div class="section-label">Method</div>
<ol class="steps-list">${s}</ol>
${e.notes?`<div class="notes-box">${e.notes}</div>`:""}
<div class="recipe-actions">
${t?`
<button class="action-btn" style="background: var(--color-text-primary); color: white;" onclick="window.location.href=window.location.pathname">Return to Login</button>
`:`
<button class="action-btn" id="save-recipe-btn" onclick="saveCurrentRecipe()">Save to Menu</button>
<button class="action-btn" id="share-recipe-btn" onclick="shareRecipeLink()">Copy Share Link</button>
`}
</div>
</div>`}window.addEventListener("DOMContentLoaded",async()=>{if(window.location.hash.startsWith("#shared="))try{const e=window.location.hash.slice(8);document.getElementById("login-overlay").innerText="Loading Shared Recipe...",document.getElementById("login-overlay").style.color="#1a1a1a";const t=await fetch(`/api/recipes/${e}`);if(!t.ok)throw new Error("Shared recipe not found");const n=await t.json();n.id=e,document.getElementById("login-overlay").style.display="none",document.querySelectorAll("#open-menu-btn, .header, .form-grid, #gen-btn, #error-area").forEach(s=>{s&&(s.style.display="none")}),document.getElementById("app-content").style.display="block",f(n,!0)}catch(e){console.error("Failed to load shared recipe",e),document.getElementById("login-overlay").innerHTML=`
<div class="login-card" style="text-align:center;">
<h2 style="font-family: 'Lora', serif; font-size: 20px;">Recipe Not Found</h2>
<p style="font-size: 14px; margin-top: 10px;">The shared recipe link appears to be invalid or has been deleted.</p>
<button class="generate-btn" style="margin-top: 16px;" onclick="window.location.href=window.location.pathname">Return to Login</button>
</div>
`}});

282
dist/index.html vendored Normal file
View File

@@ -0,0 +1,282 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Recipe Generator</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Lora:ital,wght@0,400;0,600;1,400&family=DM+Sans:wght@300;400;500&display=swap');
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--color-text-primary: #1a1a1a;
--color-text-secondary: #6b6b6b;
--color-background-primary: #ffffff;
--color-background-secondary: #f5f5f4;
--color-background-danger: #fef2f2;
--color-text-danger: #b91c1c;
--color-border-primary: #a3a3a3;
--color-border-secondary: #d4d4d4;
--color-border-tertiary: #e5e5e5;
--border-radius-md: 8px;
--border-radius-lg: 12px;
}
body {
font-family: 'DM Sans', sans-serif;
background: var(--color-background-primary);
color: var(--color-text-primary);
padding: 2rem;
display: flex;
justify-content: center;
}
.app { display: none; width: 100%; max-width: 680px; padding: 2rem 0; }
#login-overlay { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: var(--color-background-secondary); display: flex; align-items: center; justify-content: center; z-index: 1000; }
.login-card { background: var(--color-background-primary); padding: 2rem; border: 0.5px solid var(--color-border-secondary); border-radius: var(--border-radius-lg); width: 100%; max-width: 320px; box-shadow: 0 4px 12px rgba(0,0,0,0.05); }
.login-card h2 { font-family: 'Lora', serif; font-size: 24px; margin-bottom: 1.25rem; text-align: center; }
.header { margin-bottom: 2rem; border-bottom: 0.5px solid var(--color-border-tertiary); padding-bottom: 1.25rem; }
.header h1 { font-family: 'Lora', serif; font-size: 26px; font-weight: 600; letter-spacing: -0.5px; margin-bottom: 4px; }
.header p { font-size: 14px; color: var(--color-text-secondary); font-weight: 300; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 14px; }
.form-field { display: flex; flex-direction: column; gap: 6px; }
.form-field.full { grid-column: 1 / -1; }
label { font-size: 12px; font-weight: 500; color: var(--color-text-secondary); letter-spacing: 0.04em; text-transform: uppercase; }
input, textarea, select {
font-family: 'DM Sans', sans-serif; font-size: 14px; padding: 9px 12px;
border: 0.5px solid var(--color-border-secondary); border-radius: var(--border-radius-md);
background: var(--color-background-primary); color: var(--color-text-primary);
width: 100%; transition: border-color 0.15s; outline: none;
}
input:focus, textarea:focus, select:focus { border-color: var(--color-border-primary); }
textarea { resize: vertical; min-height: 72px; line-height: 1.5; }
.tags-input-wrapper {
border: 0.5px solid var(--color-border-secondary); border-radius: var(--border-radius-md);
background: var(--color-background-primary); padding: 6px 8px;
display: flex; flex-wrap: wrap; gap: 6px; align-items: center;
cursor: text; min-height: 40px; transition: border-color 0.15s;
}
.tags-input-wrapper:focus-within { border-color: var(--color-border-primary); }
.tag { background: var(--color-background-secondary); border: 0.5px solid var(--color-border-tertiary); border-radius: 4px; padding: 2px 8px; font-size: 13px; display: flex; align-items: center; gap: 5px; white-space: nowrap; }
.tag-x { cursor: pointer; color: var(--color-text-secondary); font-size: 15px; line-height: 1; }
.tag-x:hover { color: var(--color-text-primary); }
.tags-input { border: none !important; padding: 2px 4px !important; flex: 1; min-width: 100px; font-size: 14px; background: transparent !important; outline: none !important; }
.checkboxes { display: flex; flex-wrap: wrap; gap: 8px; }
.check-pill { display: flex; align-items: center; gap: 6px; padding: 5px 12px; border: 0.5px solid var(--color-border-secondary); border-radius: 20px; font-size: 13px; cursor: pointer; transition: all 0.12s; background: var(--color-background-primary); user-select: none; }
.check-pill input { display: none; }
.check-pill.checked { background: var(--color-background-secondary); border-color: var(--color-border-primary); font-weight: 500; }
.dot { width: 7px; height: 7px; border-radius: 50%; background: var(--color-border-secondary); flex-shrink: 0; }
.check-pill.checked .dot { background: var(--color-text-primary); }
.generate-btn { width: 100%; padding: 11px; background: var(--color-text-primary); color: var(--color-background-primary); border: none; border-radius: var(--border-radius-md); font-family: 'DM Sans', sans-serif; font-size: 15px; font-weight: 500; cursor: pointer; margin-top: 8px; transition: opacity 0.15s; }
.generate-btn:hover { opacity: 0.85; }
.generate-btn:disabled { opacity: 0.45; cursor: not-allowed; }
.status { text-align: center; padding: 2rem 0; color: var(--color-text-secondary); font-size: 14px; font-style: italic; }
.recipe-out { margin-top: 2.5rem; border-top: 0.5px solid var(--color-border-tertiary); padding-top: 2rem; animation: fadein 0.4s ease; }
@keyframes fadein { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
.recipe-actions { display: flex; gap: 10px; margin-top: 1.5rem; }
.action-btn { flex: 1; padding: 10px; background: var(--color-background-primary); border: 0.5px solid var(--color-border-primary); border-radius: var(--border-radius-md); font-family: 'DM Sans', sans-serif; font-size: 14px; font-weight: 500; cursor: pointer; transition: all 0.15s; }
.action-btn:hover { background: var(--color-background-secondary); }
.saved-menu-btn { position: fixed; top: 20px; right: 20px; padding: 8px 16px; background: var(--color-text-primary); color: white; border: none; border-radius: 20px; font-size: 13px; font-weight: 500; cursor: pointer; z-index: 100; box-shadow: 0 2px 8px rgba(0,0,0,0.15); }
#saved-menu { position: fixed; top: 0; right: -350px; width: 320px; height: 100vh; background: var(--color-background-primary); box-shadow: -4px 0 15px rgba(0,0,0,0.05); transition: right 0.3s ease; z-index: 200; padding: 2rem 1.5rem; overflow-y: auto; border-left: 1px solid var(--color-border-tertiary); }
#saved-menu.open { right: 0; }
.saved-menu-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem; }
.saved-menu-header h2 { font-family: 'Lora', serif; font-size: 20px; }
.close-menu { cursor: pointer; font-size: 24px; color: var(--color-text-secondary); line-height: 1; background: none; border: none; }
.saved-recipe-item { padding: 12px; border: 0.5px solid var(--color-border-secondary); border-radius: var(--border-radius-md); margin-bottom: 10px; cursor: pointer; transition: border-color 0.15s; }
.saved-recipe-item:hover { border-color: var(--color-text-primary); }
.saved-recipe-item h3 { font-size: 15px; margin-bottom: 4px; }
.saved-recipe-item p { font-size: 12px; color: var(--color-text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.recipe-title { font-family: 'Lora', serif; font-size: 28px; font-weight: 600; letter-spacing: -0.5px; margin-bottom: 6px; }
.recipe-desc { font-size: 14px; color: var(--color-text-secondary); font-style: italic; margin-bottom: 1.25rem; line-height: 1.6; }
.meta-row { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 1.75rem; }
.meta-chip { background: var(--color-background-secondary); border-radius: var(--border-radius-md); padding: 6px 14px; font-size: 13px; color: var(--color-text-secondary); }
.meta-chip strong { color: var(--color-text-primary); font-weight: 500; }
.section-label { font-size: 11px; font-weight: 500; letter-spacing: 0.08em; text-transform: uppercase; color: var(--color-text-secondary); margin-bottom: 10px; margin-top: 1.5rem; }
.ingredients-list { list-style: none; border: 0.5px solid var(--color-border-tertiary); border-radius: var(--border-radius-lg); overflow: hidden; }
.ingredients-list li { padding: 9px 16px; font-size: 14px; border-bottom: 0.5px solid var(--color-border-tertiary); display: flex; gap: 10px; align-items: baseline; }
.ingredients-list li:last-child { border-bottom: none; }
.ing-amount { font-weight: 500; min-width: 60px; }
.ing-name { color: var(--color-text-secondary); }
.steps-list { list-style: none; }
.step-item { display: flex; gap: 16px; margin-bottom: 1.25rem; }
.step-num { font-family: 'Lora', serif; font-size: 18px; font-weight: 600; color: var(--color-text-secondary); min-width: 28px; line-height: 1.5; }
.step-title { font-weight: 500; font-size: 14px; margin-bottom: 4px; }
.step-instr { font-size: 14px; line-height: 1.7; color: var(--color-text-secondary); }
.notes-box { background: var(--color-background-secondary); border-radius: 0 var(--border-radius-md) var(--border-radius-md) 0; padding: 12px 16px; font-size: 14px; line-height: 1.6; color: var(--color-text-secondary); margin-top: 1.5rem; border-left: 2px solid var(--color-border-primary); }
.error-msg { background: var(--color-background-danger); color: var(--color-text-danger); border-radius: var(--border-radius-md); padding: 12px 16px; font-size: 13px; margin-top: 1rem; line-height: 1.5; }
.spinner { display: inline-block; width: 16px; height: 16px; border: 2px solid rgba(255,255,255,0.35); border-top-color: #fff; border-radius: 50%; animation: spin 0.7s linear infinite; vertical-align: middle; margin-right: 6px; }
@keyframes spin { to { transform: rotate(360deg); } }
</style>
<script type="module" crossorigin src="/assets/index-CrgoFxjd.js"></script>
</head>
<body>
<div id="login-overlay">
<div class="login-card">
<h2 style="font-family: 'Lora', serif; font-size: 26px; font-weight: 600; letter-spacing: -0.5px; margin-bottom: 20px;">Welcome</h2>
<div class="form-field" style="margin-bottom: 12px;">
<label>Username</label>
<input type="text" id="login-username" />
</div>
<div class="form-field" style="margin-bottom: 12px;">
<label>Password</label>
<input type="password" id="login-password" />
</div>
<button class="generate-btn" id="login-btn" style="margin-top: 12px;">Sign in</button>
<div id="login-error" style="color: var(--color-text-danger); font-size: 13px; margin-top: 10px; text-align: center; display: none;">Invalid credentials</div>
</div>
</div>
<div class="app" id="app-content">
<button class="saved-menu-btn" id="open-menu-btn">View Saved Recipes</button>
<div id="saved-menu">
<div class="saved-menu-header">
<h2>Saved Recipes</h2>
<button class="close-menu" id="close-menu-btn">×</button>
</div>
<div id="saved-list">
<!-- Saved items go here -->
</div>
</div>
<div class="header">
<h1>Recipe Generator</h1>
<p>Tell the AI what you have and what you want — it handles the rest.</p>
</div>
<div class="form-grid">
<div class="form-field full">
<label>Ingredients on hand</label>
<div class="tags-input-wrapper" id="ing-wrapper">
<input class="tags-input" id="ing-input" placeholder="Type an ingredient, press Enter..." />
</div>
</div>
<div class="form-field full">
<label>What do you want to make?</label>
<input type="text" id="goal" placeholder="e.g. a hearty weeknight pasta, a light summer salad..." />
</div>
<div class="form-field full">
<label>Dietary restrictions</label>
<div class="checkboxes">
<label class="check-pill"><input type="checkbox" value="vegan"><span class="dot"></span>Vegan</label>
<label class="check-pill"><input type="checkbox" value="vegetarian"><span class="dot"></span>Vegetarian</label>
<label class="check-pill"><input type="checkbox" value="gluten-free"><span class="dot"></span>Gluten-free</label>
<label class="check-pill"><input type="checkbox" value="dairy-free"><span class="dot"></span>Dairy-free</label>
<label class="check-pill"><input type="checkbox" value="nut-free"><span class="dot"></span>Nut-free</label>
<label class="check-pill"><input type="checkbox" value="low-carb"><span class="dot"></span>Low-carb</label>
</div>
</div>
<div class="form-field">
<label>Cuisine style</label>
<select id="cuisine">
<option value="">Any cuisine</option>
<option>Italian</option><option>Mexican</option><option>Asian fusion</option>
<option>Mediterranean</option><option>French</option><option>Indian</option>
<option>American</option><option>Middle Eastern</option><option>Japanese</option><option>Thai</option>
</select>
</div>
<div class="form-field">
<label>Time available</label>
<select id="time">
<option value="">No limit</option>
<option value="under 20 minutes">Under 20 minutes</option>
<option value="under 30 minutes">Under 30 minutes</option>
<option value="under 45 minutes">Under 45 minutes</option>
<option value="under 1 hour">Under 1 hour</option>
<option value="12 hours">12 hours</option>
</select>
</div>
<div class="form-field">
<label>Skill level</label>
<select id="skill">
<option value="">Any level</option>
<option value="beginner-friendly">Beginner-friendly</option>
<option value="intermediate">Intermediate</option>
<option value="advanced">Advanced chef techniques</option>
</select>
</div>
<div class="form-field">
<label>Servings</label>
<select id="servings">
<option value="1">1</option><option value="2" selected>2</option>
<option value="4">4</option><option value="6">6</option><option value="8">8</option>
</select>
</div>
<div class="form-field full">
<label>Any other notes or preferences</label>
<textarea id="notes" placeholder="e.g. my kids hate mushrooms, I want it spicy, make it impressive enough for guests..."></textarea>
</div>
</div>
<button class="generate-btn" id="gen-btn">Generate my recipe</button>
<div id="error-area"></div>
<div id="recipe-area"></div>
</div>
</body>
</html>

View File

@@ -3,11 +3,11 @@ services:
build: .
restart: unless-stopped
ports:
- "8080:80"
environment:
- VITE_GEMINI_API_KEY=${VITE_GEMINI_API_KEY}
- APP_USERNAME=${APP_USERNAME}
- APP_PASSWORD=${APP_PASSWORD}
- "8080:3000"
env_file:
- .env
volumes:
- ./recipes:/app/recipes
deploy:
resources:
limits:

468
implementation_plan.md Normal file
View File

@@ -0,0 +1,468 @@
# Reel Recipe App — Implementation Plan
This plan covers a native iOS app that turns shared Instagram Reels and TikToks into structured, searchable recipes, backed by a Python/FastAPI worker running on your apartment desktop. Voice search and dietary intelligence are first-class features alongside reel ingestion.
---
## 1. Scope & Goals
### In scope for v1
- iOS app (SwiftUI, iOS 18+)
- Share extension accepting reels/TikToks from Instagram, TikTok, and any URL sharing surface
- Video-understanding pipeline: caption parsing → ASR → frame OCR → VLM → fusion LLM
- Voice search with on-device Whisper transcription, backend web search + LLM structuring
- Dietary preferences and allergies with per-ingredient swap suggestions on violation
- Apple Sign In authentication
- Self-hosted Postgres on apartment desktop, accessed via Cloudflare Tunnel
### Out of scope for v1
- Android
- Fridge scanning / computer vision of ingredients (carried over design language only from the SousChefAI codebase)
- Full recipe regeneration on dietary conflict (ingredient swaps only for now)
- Community / shared recipe features
- Grocery list integrations beyond Apple Reminders
### Non-goals / philosophy
- Do not scrape Instagram or TikTok from the server. All ingestion is user-initiated through the share sheet. Backend may fetch the public video via yt-dlp, but only after the user has explicitly shared it.
- Do not embed third-party API keys in the iOS binary. All LLM/VLM inference runs on the backend.
- Do not require a network round-trip for things that can reasonably happen on-device (voice transcription for search, UI state, cache).
---
## 2. Architecture Overview
```
┌─────────────────────────────────────────────┐
│ iOS App (iOS 18+) │
│ ┌────────────────┐ ┌─────────────────┐ │
│ │ Share Extension│ │ Main App │ │
│ │ (thin, <50MB) │ │ (SwiftUI) │ │
│ └────────┬───────┘ └────────┬────────┘ │
│ │ │ │
│ ┌────────┴───────────────────┴────────┐ │
│ │ App Group: pending-jobs, cache │ │
│ └────────────────┬────────────────────┘ │
│ │ │
│ ┌────────────────┴────────────────────┐ │
│ │ WhisperKit (on-device, voice only) │ │
│ └─────────────────────────────────────┘ │
└───────────────────┬─────────────────────────┘
│ HTTPS (Cloudflare Tunnel)
│ Bearer JWT
┌───────────────────┴─────────────────────────┐
│ Apartment Desktop (Ubuntu/WSL2) │
│ ┌─────────────────────────────────────┐ │
│ │ FastAPI app (uvicorn) │ │
│ │ • /auth /me /jobs /recipes │ │
│ │ • Pushes jobs onto arq queue │ │
│ └────────────┬────────────────────────┘ │
│ │ │
│ ┌────────────┴────────────┐ │
│ │ Redis (job queue) │ │
│ └────────────┬────────────┘ │
│ │ │
│ ┌────────────┴────────────────────┐ │
│ │ arq Worker(s) │ │
│ │ • yt-dlp / ffmpeg │ │
│ │ • faster-whisper (GPU) │ │
│ │ • PySceneDetect + PaddleOCR │ │
│ │ • Qwen2.5-VL-7B (GPU) │ │
│ │ • Qwen2.5-14B fusion (GPU) │ │
│ └────────────┬────────────────────┘ │
│ │ │
│ ┌────────────┴────────────────────┐ │
│ │ Postgres 16 + pgvector │ │
│ └─────────────────────────────────┘ │
└─────────────────────────────────────────────┘
```
The share extension is deliberately tiny: it reads the shared URL and any caption iOS hands over, writes a pending-job record into the App Group container, posts it to the backend, and exits. It never downloads the video, never runs ML, never exceeds ~50MB of memory. iOS's 120-ish-MB share extension ceiling is not a constraint here because we designed around it.
The main app is the primary surface. It reads pending jobs from the App Group on launch, polls job status, renders recipes, handles voice search, and manages preferences. The main app has no meaningful memory limit.
The desktop backend does all heavy work. Two processes: the FastAPI request handler (lightweight, non-blocking) and one or more arq workers that pull jobs from Redis and run the video pipeline. This split matters — you don't want Whisper blocking a health check.
---
## 3. Tech Stack
**iOS (SwiftUI, iOS 18+)**
- SwiftUI with the iOS 18 Liquid Glass materials for the polished card UI
- Apple Sign In for auth
- URLSession + async/await for networking
- SwiftData for local cache of recipes (read-through, write-through to backend)
- Share Extension target sharing an App Group container with the main app
- WhisperKit for on-device voice transcription (voice search only; not used for reels)
- APNs for job-ready notifications
**Backend (Python 3.12)**
- FastAPI with uvicorn
- SQLAlchemy 2.x async + Alembic migrations
- Postgres 16 with pgvector (for future semantic recipe search)
- Redis 7 + arq for job queue
- PyJWT + cryptography for Apple identity token verification and own session JWT issuance
- Pydantic v2 for schemas
- httpx for outbound HTTP
**ML / media stack (all on the desktop 3060 Ti + WSL2 Ubuntu)**
- yt-dlp (pinned, with a scheduled pull for updates)
- ffmpeg
- faster-whisper (large-v3-turbo, CTranslate2 backend)
- PySceneDetect for keyframe selection
- PaddleOCR (GPU) for on-screen text extraction
- Qwen2.5-VL-7B-Instruct for frame captioning (via transformers or vLLM)
- Qwen2.5-14B-Instruct for the fusion step and dietary swap suggestions
- Escape hatch: config flag to route fusion to Gemini Flash if local model is struggling or machine is offline
**Infrastructure**
- Cloudflare Tunnel to apartment desktop (existing setup)
- GitHub Actions for CI (tests, lint, migration linting)
- Eventually: Oracle Free ARM VM as a WireGuard relay (matches your existing roadmap)
---
## 4. iOS Application
### 4.1 Share Extension
The extension has exactly one job: take the URL the user shared, post it to the backend, and get out of the way.
Flow on share:
1. Read the shared NSExtensionItem; extract the URL and any caption text iOS provides
2. Check that a user session JWT exists in the App Group Keychain. If not, show an "open the app to sign in" message and bail
3. POST `/jobs/ingest-reel` with `{source_url, caption?}` and the bearer token
4. Show a brief "Recipe queued" confirmation. Dismiss
5. If the network call fails, write the job to the App Group's `pending-jobs` directory. The main app will retry on next launch
No ML, no heavy dependencies, no video processing. This keeps the extension well under the memory limit and makes the share sheet experience feel instant.
### 4.2 Main App
Five primary surfaces:
**Home / Inbox.** Shows recipes with their processing status. A just-shared recipe appears immediately as "Processing…" with a shimmer, then transitions to a full card when the backend finishes. This is where the liquid-glass card styling lives — frosted backgrounds, subtle depth, match-score badges carried over from SousChefAI.
**Recipe Detail.** Title, description, ingredients list with provenance badges ("heard in narration", "seen on-screen", "from caption"), step-by-step instructions, servings/time metadata, missing-ingredient section if any, and any dietary flags inline. If the user has an allergy to something in the recipe, that ingredient appears with a red badge and a suggested swap right next to it.
**Voice Search.** A prominent mic button opens a full-screen capture surface. WhisperKit transcribes on-device as the user speaks, showing interim results. On confirmation, the transcript goes to `/jobs/voice-search` and the user sees a liquid-glass grid of structured results streaming in.
**Saved / Library.** Their personal collection. Filter by dietary match, by source (reel vs. voice vs. manual), by time.
**Profile / Preferences.** Apple Sign In account info, dietary restrictions, allergies (explicitly separated from preferences in the UI — allergies get an "important safety info" framing, preferences are soft), nutrition goals, pantry staples.
### 4.3 On-Device Services
Only two pieces of real logic run on the device:
**WhisperKit transcription for voice search.** Model downloaded on first use (Whisper-small distilled, ~150MB — usable quality for short queries, modest size). Runs locally, no audio leaves the device for voice search.
**SwiftData local cache.** Read-through cache of the user's recipes so the app opens to populated content even when the backend is unreachable. Writes go to the backend first; local cache updates on success.
### 4.4 Service Layer (Protocol-Based)
Carry over the protocol-based architecture from the SousChefAI codebase. `RecipeService`, `AuthService`, `JobService`, `VoiceSearchService` are protocols. Concrete implementations hit the backend. Mock implementations are used in previews and tests. This was already done well in the starter code — the Gemini-direct services get removed and replaced with thin clients that hit `/recipes`, `/jobs`, etc.
---
## 5. Backend (FastAPI)
### 5.1 Process Model
Two long-running processes:
- **API process** — `uvicorn app.main:app`. Handles HTTP. Does not do long-running work; every heavy operation is enqueued.
- **Worker process(es)** — `arq app.worker.WorkerSettings`. Pulls jobs from Redis, runs the video pipeline. Start with one worker; adjust concurrency based on GPU utilization.
Both run under systemd on the desktop. Redis runs as a third systemd unit. Postgres is also systemd-managed on the same box.
### 5.2 Module Layout
```
app/
main.py # FastAPI app factory, middleware
config.py # Pydantic settings
db.py # SQLAlchemy engine, session
auth/
apple.py # Verify Apple identity tokens
jwt.py # Issue/verify own session JWTs
deps.py # FastAPI dependencies (current_user, etc)
models/ # SQLAlchemy models
schemas/ # Pydantic request/response schemas
routers/
auth.py
me.py
jobs.py
recipes.py
services/
ingest.py # High-level reel ingestion orchestration
voice_search.py
dietary.py # Violation detection + swap suggestion
pipeline/
download.py # yt-dlp wrapper
audio.py # ffmpeg + faster-whisper
frames.py # PySceneDetect + OCR
vlm.py # Qwen2.5-VL client
fusion.py # Final structured-recipe generation
worker.py # arq worker settings + job functions
migrations/ # Alembic
tests/
```
### 5.3 API Surface
All endpoints require a Bearer session JWT except `/auth/apple` and health checks.
```
POST /auth/apple Exchange Apple identity token → session JWT + refresh
POST /auth/refresh Refresh session JWT
GET /me Current user + preferences
PUT /me/preferences Update dietary restrictions, allergies, goals
POST /jobs/ingest-reel Body: {source_url, caption?}. Returns job_id.
POST /jobs/voice-search Body: {query}. Returns job_id.
POST /jobs/scale-recipe Body: {recipe_id, limiting_ingredient, quantity}
GET /jobs/{id} Poll job status
GET /recipes List current user's recipes (paginated)
GET /recipes/{id} Recipe detail with ingredients + steps
PUT /recipes/{id} Edit a recipe
DELETE /recipes/{id}
POST /recipes/{id}/save Explicit save to library (vs. inbox)
```
Job-based endpoints always return quickly with a `job_id`. The client polls (or receives an APNs push) and then fetches the final object. This pattern matters because the video pipeline can take 20-40 seconds and you can't hold a connection open that long reliably through Cloudflare Tunnel.
### 5.4 Auth Flow (Apple Sign In)
1. iOS app performs Apple Sign In, receives an identity token (a JWT signed by Apple)
2. App POSTs the token to `/auth/apple`
3. Backend verifies the token: fetches Apple's public keys from `appleid.apple.com/auth/keys`, validates signature, checks audience (your app's bundle ID) and expiration
4. Extract the stable `sub` (Apple's opaque user ID). Look up or create a `users` row keyed on `apple_user_id`
5. Issue a session JWT (15 min) and a refresh token (30 days). Return both
6. Client stores them in the App Group Keychain so the share extension can access them
The session JWT contains `user_id` and `exp`. Every subsequent request is authenticated against it. The refresh endpoint issues a new session JWT given a valid refresh token.
Important: the share extension does not initiate sign-in. If there's no valid JWT when a user shares, the extension tells them to open the main app once. This is fine — they'll have signed in during onboarding.
---
## 6. Database Schema
```sql
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
apple_user_id TEXT UNIQUE NOT NULL,
email TEXT,
display_name TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE user_preferences (
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
dietary_restrictions TEXT[] NOT NULL DEFAULT '{}', -- e.g. vegan, vegetarian, halal
allergies TEXT[] NOT NULL DEFAULT '{}', -- e.g. peanut, shellfish, dairy
nutrition_goals TEXT,
pantry_staples TEXT[] NOT NULL DEFAULT '{}',
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE recipes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
description TEXT,
source_type TEXT NOT NULL, -- reel, voice_search, manual, web
source_url TEXT,
source_platform TEXT, -- instagram, tiktok, web
caption TEXT,
transcript TEXT,
servings INT,
estimated_time TEXT,
status TEXT NOT NULL, -- pending, processing, ready, failed
error_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_recipes_user_status ON recipes(user_id, status);
CREATE TABLE ingredients (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
recipe_id UUID NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
display_order INT NOT NULL,
name TEXT NOT NULL,
quantity NUMERIC,
unit TEXT,
raw_text TEXT, -- original phrasing from source
provenance TEXT NOT NULL, -- caption, transcript, overlay, vlm_inferred, user
confidence REAL NOT NULL DEFAULT 1.0,
violation_type TEXT, -- allergy, restriction, null
violation_label TEXT, -- peanut, dairy, etc.
suggested_swap TEXT
);
CREATE INDEX idx_ingredients_recipe ON ingredients(recipe_id);
CREATE TABLE recipe_steps (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
recipe_id UUID NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
step_order INT NOT NULL,
instruction TEXT NOT NULL,
timer_seconds INT
);
CREATE INDEX idx_steps_recipe ON recipe_steps(recipe_id, step_order);
CREATE TABLE jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type TEXT NOT NULL, -- ingest_reel, voice_search, scale_recipe
status TEXT NOT NULL, -- queued, processing, done, failed
payload JSONB NOT NULL,
result JSONB,
recipe_id UUID REFERENCES recipes(id) ON DELETE SET NULL,
error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ
);
CREATE INDEX idx_jobs_user_status ON jobs(user_id, status);
```
Allergies are stored separately from restrictions deliberately — the UI treats them with different severity, the dietary check weights them differently, and this separation scales cleanly to medical-severity flagging later if needed.
The `provenance` field on `ingredients` is the product moat. Pestle and similar apps output flat JSON. We can show "3 tbsp olive oil (seen on screen)" vs. "pinch of salt (inferred)" with visual distinction, giving users calibrated trust.
---
## 7. Pipelines
### 7.1 Reel Ingestion
When the worker picks up an `ingest_reel` job, it runs roughly this sequence. Stages short-circuit as soon as we have confident structured output.
**Stage 1 — Download.** yt-dlp fetches the video and extracts metadata. The caption is usually populated by yt-dlp from the post description. Typical reel: 5-15 MB, 30-90 seconds. Store in `/tmp/jobs/{job_id}/`.
**Stage 2 — Caption-first extraction.** Feed the caption to the fusion LLM with a structured-output prompt asking for a recipe JSON. If the model returns a recipe with all required fields populated and confidence above a threshold, skip the rest and go straight to dietary check. This is the Pestle path, and it handles probably 50-70% of reels at near-zero latency and compute cost.
**Stage 3 — Caption link extraction.** If the caption contains an external URL (common — many creators link their blog), fetch it, look for `schema.org/Recipe` JSON-LD, and use that if present. This handles another chunk of cases cleanly.
**Stage 4 — Audio + ASR.** ffmpeg extracts mono 16kHz WAV. faster-whisper with large-v3-turbo transcribes with word-level timestamps. Typical runtime: 3-6 seconds for a 60s clip on a 3060 Ti.
**Stage 5 — Frame sampling.** PySceneDetect identifies scene changes — reels cut exactly when something important happens, so this outperforms uniform sampling. Cap at 12 frames. ffmpeg extracts them as JPEGs.
**Stage 6 — OCR.** PaddleOCR on each frame. On-screen text in reels is often the most reliable source of exact ingredient quantities ("2 tbsp olive oil" flashed over a pan shot). Keep detected text with per-frame timestamps.
**Stage 7 — VLM captioning.** Qwen2.5-VL-7B describes each frame with a prompt focused on cooking context ("What ingredients, tools, and cooking actions are visible? What stage of preparation does this show?"). Keep the short descriptions. Typical: 2-4 seconds per frame in batched mode.
**Stage 8 — Fusion.** Qwen2.5-14B gets: caption, transcript (with timestamps), OCR text (with timestamps), VLM frame descriptions (with timestamps). The prompt asks for structured JSON with a per-field provenance tag. This is the stage that distinguishes "we confirmed from the overlay" from "we inferred from the voiceover".
**Stage 9 — Dietary check.** See §7.3.
**Stage 10 — Persist.** Write recipe, ingredients, steps rows. Mark job done. Send APNs push if the user is opted in.
Between ~15 and ~40 seconds end-to-end depending on how many stages were needed. Caption-only path: 2-4 seconds.
### 7.2 Voice Search
**On-device (iOS).** User holds the mic, WhisperKit transcribes with interim results shown live. On confirm, the app posts the final transcript to `/jobs/voice-search`.
**Backend.**
1. LLM interprets the query into structured search terms, extracting any implicit constraints ("dinner tonight, quick, high protein" → `{meal_type: dinner, max_time: 30m, dietary_emphasis: high_protein}`)
2. Constraints are merged with the user's stored preferences and allergies
3. Backend performs a web search (Brave Search API or Google Custom Search — pick one at implementation time, Brave is cheaper with a better API) for recipe URLs matching the terms
4. Fetch the top ~5 results with httpx. For each, look for JSON-LD `@type: Recipe` — most food blogs have this. For pages without JSON-LD, feed the HTML through a structuring LLM call
5. Filter results against the user's allergies as a hard gate, and score against soft preferences
6. Return the top 3-5 as structured recipes
Voice search recipes are not auto-saved — they appear in the result view and the user taps to save to their library.
### 7.3 Dietary Intelligence
Two severity tiers internally, visually distinct in the UI:
**Allergies (hard).** Ingredient match triggers a red warning. The fusion LLM is also asked to propose an ingredient-level swap in the same call that generates the recipe, so it's stored alongside the ingredient row. If a safe swap cannot be produced ("this recipe is fundamentally built around peanuts"), the field is left null and the UI shows "no safe substitute — consider skipping this recipe".
**Restrictions (soft).** Vegetarian, vegan, gluten-free, etc. Orange warning, always accompanied by a swap suggestion. These are treated as preferences, not safety issues.
Detection is straightforward name matching plus synonym expansion maintained as a small dictionary — "peanut" matches peanut, peanut butter, peanut oil, groundnut, arachis. Keep this dictionary in the backend repo, version-controlled. LLM is involved only for generating the swap text, not for detection (detection needs to be reliable and fast).
This runs after the fusion stage so the swap suggestions can be informed by the full recipe context (the LLM knows what role the ingredient plays).
---
## 8. Infrastructure & Deployment
**Desktop (primary dev/prod for v1).** Ubuntu 24 on WSL2 as you currently have it. Four systemd units: `postgres`, `redis`, `recipe-api` (uvicorn), `recipe-worker` (arq). A simple unified logging setup — journalctl is fine for now.
**Cloudflare Tunnel** exposes `api.wahwa.com` (or similar subdomain) to the FastAPI port. Same pattern as your existing setup. No special handling needed for the video pipeline since videos don't traverse the tunnel — they're downloaded server-side.
**Secrets** live in a `.env` file sourced by systemd. Apple keys, DB credentials, search API key, optional Gemini fallback key.
**Migrations.** Alembic. `alembic upgrade head` runs as a `ExecStartPre=` step on the API unit so deploys apply migrations automatically.
**CI (GitHub Actions).** Lint, type-check, unit tests, migration check (Alembic can detect schema drift). Not doing CD to the apartment box initially — you'll deploy by SSH and `git pull` + restart units. If that becomes annoying, wire up a webhook.
**iOS distribution.** TestFlight for internal testing. Use your existing paid Apple Developer account. Standard provisioning.
**Monitoring.** For v1, a simple `/health` endpoint on the API and a daily cron emailing you the count of jobs completed and failed in the last 24h. If you want nicer dashboards, add Grafana + Loki later, but that's polish.
---
## 9. Scaling Roadmap
The v1 setup has known bottlenecks. Here's the progression if/when usage demands it.
**Phase 1 (now): apartment desktop, everything on one box.** Fine for you alone and a handful of beta testers. Caveats: a power outage or ISP glitch takes the service down; desktop-off when you travel is an outage; all GPU throughput is shared with whatever else you're running locally.
**Phase 2: Oracle Free ARM VM as edge relay.** The Oracle VM terminates Cloudflare Tunnel traffic and forwards over WireGuard to your desktop. This matches your existing roadmap for Gitea. Benefit: the public endpoint stays up even when your desktop is rebooting; you can fall back to a "still processing — desktop is restarting" state rather than a hard 502. Desktop does all real work.
**Phase 3: managed Postgres.** Neon or Supabase Postgres when you start caring about automated backups, PITR, read replicas, or just not babysitting a DB. The schema is vanilla Postgres, the move is trivial — change the DSN. Do this before scaling users, not after.
**Phase 4: detach API from worker.** The API moves to a cheap VPS (Fly, Hetzner). Workers stay on the GPU machine (your apartment now, or a colocated box, or RunPod/Lambda GPU). Communication is over Redis still; Redis moves to the VPS side. The GPU machine only handles arq jobs and needs no inbound public traffic.
**Phase 5: multi-region or managed GPU.** If latency for Asia/Europe users matters, or if your apartment GPU is constantly saturated, move workers to a managed GPU provider (RunPod has pay-per-second GPU; Modal and Beam are similar). The fusion/VLM stage is where cost lives; swap to Gemini Flash at that point if per-call cost undercuts self-hosted amortization.
**Phase 6: if the app takes off.** Object storage for thumbnails and cached video frames (S3/R2). CDN for recipe images. A separate analytics pipeline. User-generated content moderation. Most of this is generic scaling and not worth pre-designing.
At each phase, the code shouldn't change much — the whole point of putting ML behind a worker queue and keeping API stateless is that you can redraw the deployment topology without refactoring.
---
## 10. Build Order / Milestones
Rough weekly targets assuming focused part-time work. Collapse where you're fast, expand where something fights back.
**M0 — Scaffolding (week 1).** New iOS project scaffolded with SwiftUI + share extension target + App Group. FastAPI skeleton with `/health`, Alembic baseline migration, Postgres running, Redis running, arq hello-world. Cloudflare Tunnel pointed at the API. Apple Sign In round-trip working end-to-end: iOS app signs in, backend verifies the token, issues session JWT, iOS stores and sends it. No real features yet; just prove the whole loop closes.
**M1 — Caption-first reel ingestion (weeks 2-3).** Share extension writes job and POSTs. Worker runs yt-dlp, extracts caption, runs caption-only LLM parse, writes recipe to DB. iOS main app shows recipe list and detail view with the liquid-glass card design ported from SousChefAI. This alone is a working product for maybe 60% of reels and a satisfying demo.
**M2 — Full video understanding (weeks 4-6).** Add stages 4-8 from §7.1. faster-whisper integration first (clean visible improvement). Then frames + OCR. Then VLM. Then fusion prompt engineering — this is where real time gets spent; the prompt for fusion is the heart of the app's quality. Per-field provenance rendered in the UI.
**M3 — Voice search (week 7).** WhisperKit integration. `/jobs/voice-search` endpoint. Web search integration (Brave). HTML-to-recipe extraction. Results UI.
**M4 — Dietary intelligence (week 8).** Preferences onboarding flow. Allergy dictionary. Violation detection during fusion. Swap-suggestion prompt. UI treatment for allergy vs. restriction severity.
**M5 — Polish and reliability (weeks 9-10).** APNs push on job completion. Offline-queue handling in the share extension. Error states throughout. Recipe scaling feature from the SousChef codebase (already works, just needs the backend route). Oracle ARM relay setup for Phase 2 resilience. TestFlight build for beta testers.
Two months of part-time work, plus or minus, to reach a TestFlight-ready beta. The v1 defined here is larger than Pestle's feature set on the reel-understanding side specifically, matches or exceeds on dietary handling, and adds voice search as a net-new differentiator.
---
## 11. Open Risks & Decisions Deferred
**yt-dlp breakage.** Instagram periodically changes things that break yt-dlp for days at a time. Mitigation: pin a known-good version, monitor, have a manual-update playbook, keep an eye on the yt-dlp issue tracker. Longer-term: if breakage becomes frequent enough to hurt UX, consider a fallback where the iOS share extension actually downloads the video from the reel before POSTing — iOS gets the video as part of the share payload sometimes, though this depends on Instagram's share sheet contract and isn't guaranteed.
**Local model quality vs. cost.** Qwen2.5-VL-7B and Qwen2.5-14B are good but not Gemini-3-Pro good. If the fusion stage is producing poor recipes, the escape hatch is to route fusion to Gemini 2.5 Flash or 3 Flash (order of a cent per reel, still cheaper than Pestle's on-device compute amortized over dev time). Build the fusion layer with a clean model-swap interface from day one so this is a config change.
**Desktop uptime.** Your desktop going down while you're out of town means no service. Phase 2 of the scaling roadmap mitigates but doesn't eliminate this. For beta testers, be upfront that the service is best-effort during prototyping.
**Share extension storage of refresh tokens.** The refresh token lives in the App Group Keychain, accessible to both the extension and the main app. This is a standard pattern but it's worth double-checking the Keychain access group configuration at build time — if you get it wrong the extension can't read what the main app wrote, and the failure mode is silent.
**Dietary dictionary completeness.** The allergy/restriction synonym dictionary needs careful curation. "Dairy" is a category, not an ingredient — it has to match milk, butter, cream, cheese, yogurt, whey, casein, and so on. Getting this right affects the core safety feature. Plan an explicit audit pass on the dictionary before exposing the allergy feature to beta testers.
**WhisperKit model size on first use.** First-time voice search will download ~150 MB. Either pre-download on app install (adds to the install size) or show a clear first-use spinner. Pre-downloading is nicer UX but makes the app heavier; showing a spinner on first use is fine if it's clearly communicated.
**Fusion prompt engineering.** This is the dominant quality lever and it's the least predictable piece of work. Budget roughly twice what you think it'll take. Keep a corpus of 20-30 test reels representing different recipe types (quick snacks, long-form cooking, baking, drinks) and evaluate changes against that set.

View File

@@ -85,6 +85,22 @@
.recipe-out { margin-top: 2.5rem; border-top: 0.5px solid var(--color-border-tertiary); padding-top: 2rem; animation: fadein 0.4s ease; }
@keyframes fadein { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
.recipe-actions { display: flex; gap: 10px; margin-top: 1.5rem; }
.action-btn { flex: 1; padding: 10px; background: var(--color-background-primary); border: 0.5px solid var(--color-border-primary); border-radius: var(--border-radius-md); font-family: 'DM Sans', sans-serif; font-size: 14px; font-weight: 500; cursor: pointer; transition: all 0.15s; }
.action-btn:hover { background: var(--color-background-secondary); }
.saved-menu-btn { position: fixed; top: 20px; right: 20px; padding: 8px 16px; background: var(--color-text-primary); color: white; border: none; border-radius: 20px; font-size: 13px; font-weight: 500; cursor: pointer; z-index: 100; box-shadow: 0 2px 8px rgba(0,0,0,0.15); }
#saved-menu { position: fixed; top: 0; right: -350px; width: 320px; height: 100vh; background: var(--color-background-primary); box-shadow: -4px 0 15px rgba(0,0,0,0.05); transition: right 0.3s ease; z-index: 200; padding: 2rem 1.5rem; overflow-y: auto; border-left: 1px solid var(--color-border-tertiary); }
#saved-menu.open { right: 0; }
.saved-menu-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem; }
.saved-menu-header h2 { font-family: 'Lora', serif; font-size: 20px; }
.close-menu { cursor: pointer; font-size: 24px; color: var(--color-text-secondary); line-height: 1; background: none; border: none; }
.saved-recipe-item { padding: 12px; border: 0.5px solid var(--color-border-secondary); border-radius: var(--border-radius-md); margin-bottom: 10px; cursor: pointer; transition: border-color 0.15s; }
.saved-recipe-item:hover { border-color: var(--color-text-primary); }
.saved-recipe-item h3 { font-size: 15px; margin-bottom: 4px; }
.saved-recipe-item p { font-size: 12px; color: var(--color-text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.recipe-title { font-family: 'Lora', serif; font-size: 28px; font-weight: 600; letter-spacing: -0.5px; margin-bottom: 6px; }
.recipe-desc { font-size: 14px; color: var(--color-text-secondary); font-style: italic; margin-bottom: 1.25rem; line-height: 1.6; }
@@ -132,11 +148,37 @@
</div>
<div class="app" id="app-content">
<button class="saved-menu-btn" id="open-menu-btn">View Saved Recipes</button>
<div id="saved-menu">
<div class="saved-menu-header">
<h2>Saved Recipes</h2>
<button class="close-menu" id="close-menu-btn">×</button>
</div>
<div id="saved-list">
<!-- Saved items go here -->
</div>
</div>
<div class="header">
<h1>Recipe Generator</h1>
<p>Tell the AI what you have and what you want — it handles the rest.</p>
</div>
<!-- Reel Ingestion Section -->
<div class="form-grid" style="margin-bottom: 2rem; border: 1px solid var(--color-border-primary); padding: 1.5rem; border-radius: var(--border-radius-md); background: rgba(0, 0, 0, 0.02);">
<div class="form-field full" style="margin-bottom: 0;">
<label style="font-weight: 600; color: var(--color-text-primary);">Extract Recipe from Instagram/TikTok Reel</label>
<p style="font-size: 13px; color: var(--color-text-secondary); margin-top: 4px; margin-bottom: 12px;">Paste a link to extract frames and audio, and let the VLM structure the recipe.</p>
<div style="display: flex; gap: 10px;">
<input type="text" id="reel-url" placeholder="https://www.instagram.com/reel/..." style="flex: 1;" />
</div>
<button class="generate-btn" id="ingest-btn" style="margin-top: 12px; background: var(--color-background-primary); color: var(--color-text-primary); border: 1px solid var(--color-text-primary);">Extract from Reel</button>
</div>
</div>
<div style="text-align: center; margin-bottom: 2rem; font-family: 'Lora', serif; color: var(--color-text-secondary); font-style: italic;">— OR GENERATE FROM SCRATCH —</div>
<div class="form-grid">
<div class="form-field full">
<label>Ingredients on hand</label>
@@ -250,8 +292,10 @@
const ingWrapper = document.getElementById('ing-wrapper');
const ingInput = document.getElementById('ing-input');
const genBtn = document.getElementById('gen-btn');
const ingestBtn = document.getElementById('ingest-btn');
genBtn.addEventListener('click', generate);
ingestBtn.addEventListener('click', ingestReel);
ingWrapper.addEventListener('click', () => ingInput.focus());
ingInput.addEventListener('keydown', e => {
@@ -366,11 +410,167 @@ Rules: use provided ingredients as the base, add pantry staples as needed, 51
btn.innerHTML = 'Generate my recipe';
}
async function ingestReel() {
const url = document.getElementById('reel-url').value.trim();
if (!url) {
showError('Please paste a valid Instagram or TikTok Reel URL.');
return;
}
const btn = document.getElementById('ingest-btn');
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span>Extracting & Analyzing Video... (This takes 15-30s) ';
document.getElementById('error-area').innerHTML = '';
document.getElementById('recipe-area').innerHTML = '<div class="status">Downloading reel and sending to Gemini Vision...</div>';
try {
const resp = await fetch('/api/ingest', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: sessionUser,
password: sessionPass,
url: url
})
});
const data = await resp.json();
if (!resp.ok) {
throw new Error(data.error || `Server error ${resp.status}`);
}
// Render the recipe
renderRecipe(data.recipe);
currentRecipeId = data.id;
document.getElementById('recipe-area').scrollIntoView({ behavior: 'smooth' });
} catch (err) {
document.getElementById('recipe-area').innerHTML = '';
showError('Error processing reel: ' + (err.message || 'Unknown error. Please try again.'));
} finally {
btn.disabled = false;
btn.innerHTML = 'Extract from Reel';
}
}
function showError(msg) {
document.getElementById('error-area').innerHTML = `<div class="error-msg">${msg}</div>`;
}
function renderRecipe(r) {
let currentRecipe = null;
let currentRecipeId = null;
const savedMenu = document.getElementById('saved-menu');
const savedList = document.getElementById('saved-list');
document.getElementById('open-menu-btn').addEventListener('click', async () => {
savedMenu.classList.add('open');
await fetchSavedRecipes();
});
document.getElementById('close-menu-btn').addEventListener('click', () => {
savedMenu.classList.remove('open');
});
async function fetchSavedRecipes() {
savedList.innerHTML = '<p style="font-size: 13px; color: var(--color-text-secondary); text-align: center;">Loading...</p>';
try {
const res = await fetch('/api/recipes', {
headers: {
'x-username': sessionUser,
'x-password': sessionPass
}
});
if (!res.ok) throw new Error('Failed to fetch');
const recipes = await res.json();
if (recipes.length === 0) {
savedList.innerHTML = '<p style="font-size: 13px; color: var(--color-text-secondary); text-align: center;">No saved recipes yet.</p>';
return;
}
// Reverse so newest is first
savedList.innerHTML = recipes.reverse().map(r => `
<div class="saved-recipe-item" onclick="loadSavedRecipe('${r.id}')">
<h3>${r.title}</h3>
<p>${r.description}</p>
</div>
`).join('');
} catch (e) {
savedList.innerHTML = '<p style="font-size: 13px; color: var(--color-text-danger); text-align: center;">Error loading recipes.</p>';
}
}
window.loadSavedRecipe = async function(id) {
savedMenu.classList.remove('open');
try {
const res = await fetch(`/api/recipes/${id}`);
if (!res.ok) throw new Error('Failed to fetch recipe');
const recipe = await res.json();
recipe.id = id;
renderRecipe(recipe);
document.getElementById('recipe-area').scrollIntoView({ behavior: 'smooth' });
} catch (err) {
console.error(err);
}
};
window.saveCurrentRecipe = async function() {
if (!currentRecipe) return;
const btn = document.getElementById('save-recipe-btn');
const originalText = btn.innerText;
btn.innerText = 'Saving...';
btn.disabled = true;
try {
const res = await fetch('/api/recipes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: sessionUser,
password: sessionPass,
recipe: currentRecipe
})
});
if (res.ok) {
const data = await res.json();
currentRecipeId = data.id;
currentRecipe.id = data.id;
btn.innerText = 'Saved!';
} else {
throw new Error('Save failed');
}
} catch (err) {
btn.innerText = "Error Saving";
setTimeout(() => {
btn.innerText = originalText;
btn.disabled = false;
}, 2000);
}
};
window.shareRecipeLink = function() {
if (!currentRecipeId) {
alert("Please save the recipe first to share it!");
return;
}
const url = `${window.location.origin}${window.location.pathname}#shared=${currentRecipeId}`;
navigator.clipboard.writeText(url).then(() => {
const btn = document.getElementById('share-recipe-btn');
const oldText = btn.innerText;
btn.innerText = 'Link Copied!';
setTimeout(() => btn.innerText = oldText, 2000);
});
};
function renderRecipe(r, isSharedView = false) {
currentRecipe = r;
currentRecipeId = r.id || null;
const ings = (r.ingredients || []).map(ing =>
`<li><span class="ing-amount">${ing.amount}${ing.unit ? ' ' + ing.unit : ''}</span><span class="ing-name">${ing.name}</span></li>`
).join('');
@@ -399,8 +599,54 @@ Rules: use provided ingredients as the base, add pantry staples as needed, 51
<div class="section-label">Method</div>
<ol class="steps-list">${steps}</ol>
${r.notes ? `<div class="notes-box">${r.notes}</div>` : ''}
<div class="recipe-actions">
${isSharedView ? `
<button class="action-btn" style="background: var(--color-text-primary); color: white;" onclick="window.location.href=window.location.pathname">Return to Login</button>
` : `
<button class="action-btn" id="save-recipe-btn" onclick="saveCurrentRecipe()">Save to Menu</button>
<button class="action-btn" id="share-recipe-btn" onclick="shareRecipeLink()">Copy Share Link</button>
`}
</div>
</div>`;
}
// Check for shared recipe in URL hash on load
window.addEventListener('DOMContentLoaded', async () => {
if (window.location.hash.startsWith('#shared=')) {
try {
const recipeId = window.location.hash.slice(8);
document.getElementById('login-overlay').innerText = 'Loading Shared Recipe...';
document.getElementById('login-overlay').style.color = '#1a1a1a';
const res = await fetch(`/api/recipes/${recipeId}`);
if (!res.ok) throw new Error('Shared recipe not found');
const r = await res.json();
r.id = recipeId;
// Hide generation UI elements so ONLY the recipe is visible
document.getElementById('login-overlay').style.display = 'none';
document.querySelectorAll('#open-menu-btn, .header, .form-grid, #gen-btn, #error-area').forEach(el => {
if(el) el.style.display = 'none';
});
document.getElementById('app-content').style.display = 'block';
// Disable save button or generator elements since we are in a shared view state if you desire,
// but for now let's just render the recipe directly.
renderRecipe(r, true);
} catch (e) {
console.error("Failed to load shared recipe", e);
document.getElementById('login-overlay').innerHTML = `
<div class="login-card" style="text-align:center;">
<h2 style="font-family: 'Lora', serif; font-size: 20px;">Recipe Not Found</h2>
<p style="font-size: 14px; margin-top: 10px;">The shared recipe link appears to be invalid or has been deleted.</p>
<button class="generate-btn" style="margin-top: 16px;" onclick="window.location.href=window.location.pathname">Return to Login</button>
</div>
`;
}
}
});
</script>
</body>
</html>

14
package-lock.json generated
View File

@@ -8,9 +8,10 @@
"name": "recipe-generator",
"version": "1.0.0",
"dependencies": {
"@google/generative-ai": "^0.24.1",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.21.2"
"dotenv": "^16.6.1",
"express": "^4.22.1"
},
"devDependencies": {
"vite": "^5.0.0"
@@ -407,6 +408,15 @@
"node": ">=12"
}
},
"node_modules/@google/generative-ai": {
"version": "0.24.1",
"resolved": "https://registry.npmjs.org/@google/generative-ai/-/generative-ai-0.24.1.tgz",
"integrity": "sha512-MqO+MLfM6kjxcKoy0p1wRzG3b4ZZXtPI+z2IE26UogS2Cm/XHO+7gGRBh6gcJsOiIVoH93UwKvW4HdgiOZCy9Q==",
"license": "Apache-2.0",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz",

View File

@@ -13,8 +13,9 @@
"vite": "^5.0.0"
},
"dependencies": {
"@google/generative-ai": "^0.24.1",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.21.2"
"dotenv": "^16.6.1",
"express": "^4.22.1"
}
}

View File

@@ -0,0 +1 @@
{"title":"20-Minute Protein-Packed Tofu & Yogurt Penne","description":"This lightning-fast, vegetarian dish delivers a hearty dose of protein in under 20 minutes, perfect for a busy weeknight. Al dente penne pasta is tossed with savory pan-fried tofu, tender sautéed onions and tomatoes, and a creamy, protein-rich sauce made from Greek yogurt and eggs, finished with fresh chives and a hint of warm cloves.","servings":8,"prepTime":"10 minutes","cookTime":"10 minutes","ingredients":[{"amount":"1.5 pounds","unit":"","name":"penne pasta"},{"amount":"3 (14-ounce)","unit":"blocks","name":"extra-firm tofu"},{"amount":"4 tablespoons","unit":"","name":"vegetable oil, divided"},{"amount":"1 large","unit":"","name":"yellow onion, finely diced"},{"amount":"2 large","unit":"","name":"ripe tomatoes, roughly chopped"},{"amount":"1.5 cups","unit":"","name":"plain Greek yogurt"},{"amount":"3 large","unit":"","name":"eggs"},{"amount":"0.25 teaspoon","unit":"","name":"ground cloves"},{"amount":"0.5 cup","unit":"","name":"fresh chives, chopped"},{"amount":"2 tablespoons","unit":"","name":"kosher salt, plus more to taste"},{"amount":"0.5 teaspoon","unit":"","name":"black pepper, plus more to taste"},{"amount":"6 quarts","unit":"","name":"water, for pasta"}],"steps":[{"title":"Prepare Pasta Water & Tofu","instruction":"Bring 6 quarts (5.7 liters) of water to a rolling boil over high heat in a large stockpot. While the water heats, drain the 3 blocks of extra-firm tofu, gently press out excess water using paper towels or a clean kitchen towel for 5 minutes, then cut the tofu into 1/2-inch (1.25 cm) cubes."},{"title":"Cook Tofu & Start Pasta","instruction":"Heat 3 tablespoons of vegetable oil in a large non-stick skillet over medium-high heat. Add cubed tofu in a single layer, ensuring not to overcrowd the pan, and cook for 4-6 minutes, stirring occasionally, until all sides are golden brown and slightly crispy to the touch. Once the pasta water is boiling, add 2 tablespoons of kosher salt, then add 1.5 pounds (680g) of penne pasta and cook according to package directions, typically 10-12 minutes, stirring occasionally, until al dente tender but still firm to the bite. Reserve 1 cup of pasta water before draining the penne."},{"title":"Sauté Aromatics","instruction":"While the pasta cooks, return to the skillet used for tofu (or use a separate skillet to save time, adding 1 tablespoon vegetable oil) and reduce heat to medium. Add the finely diced large yellow onion and sauté for 5-7 minutes, stirring frequently, until translucent, softened, and releasing a sweet aroma. Add the roughly chopped large ripe tomatoes and cook for another 3-4 minutes, stirring, until the tomatoes begin to break down and release their juices, creating a rustic sauce."},{"title":"Prepare Creamy Yogurt Sauce","instruction":"In a medium bowl, whisk together the 1.5 cups (360g) plain Greek yogurt, 3 large eggs, 1/4 teaspoon ground cloves, 1 teaspoon salt, and 1/2 teaspoon black pepper until the mixture is smooth and well combined, ensuring no lumps remain from the yogurt."},{"title":"Combine and Serve","instruction":"Immediately after draining, return the hot penne pasta to the large stockpot. Add the cooked golden-brown tofu cubes, the sautéed onion and tomato mixture, and then pour the creamy Greek yogurt-egg mixture over the hot ingredients. Toss vigorously and continuously for 1-2 minutes, using tongs, ensuring the sauce coats all the pasta evenly and the residual heat from the pasta gently cooks and thickens the egg into a creamy, cohesive coating. If the sauce seems too thick, add a tablespoon or two of the reserved pasta water until the desired creamy consistency is reached. Stir in 1/2 cup of chopped fresh chives just before serving."}],"notes":"The key to the creamy sauce is to add the yogurt-egg mixture to the *hot* pasta immediately after draining, then toss quickly and continuously. The residual heat will gently cook the egg without scrambling it, creating a smooth, rich coating. Do not return the pot to direct heat once the yogurt-egg mixture is added."}

View File

@@ -0,0 +1,131 @@
{
"title": "Spicy Salmon Poke Bowl",
"description": "A vibrant and flavorful poke bowl featuring crispy air-fried salmon, seasoned sushi rice, and a colorful array of fresh vegetables and pickled red onions. Quick to prepare and perfect for a healthy meal.",
"servings": 2,
"prepTime": "25 minutes",
"cookTime": "15 minutes",
"ingredients": [
{
"amount": 1,
"unit": "lb",
"name": "Salmon fillet, skin removed"
},
{
"amount": 0.25,
"unit": "cup",
"name": "Spicy mayo"
},
{
"amount": 1,
"unit": "tbsp",
"name": "Chili garlic oil"
},
{
"amount": null,
"unit": "to taste",
"name": "Salt"
},
{
"amount": null,
"unit": "to taste",
"name": "Freshly ground black pepper"
},
{
"amount": 2,
"unit": "cups",
"name": "Cooked sushi rice"
},
{
"amount": 1,
"unit": "tbsp",
"name": "Apple cider vinegar"
},
{
"amount": 1,
"unit": "tsp",
"name": "Sugar (for rice)"
},
{
"amount": 1,
"unit": "",
"name": "Red onion, thinly sliced"
},
{
"amount": 0.5,
"unit": "cup",
"name": "Red wine vinegar (for pickling)"
},
{
"amount": 1,
"unit": "tbsp",
"name": "Sugar (for pickling)"
},
{
"amount": 0.5,
"unit": "cup",
"name": "Water (for pickling)"
},
{
"amount": 2,
"unit": "",
"name": "Cucumbers, diced"
},
{
"amount": 0.25,
"unit": "head",
"name": "Red cabbage, shredded"
},
{
"amount": 2,
"unit": "",
"name": "Green onions, chopped"
},
{
"amount": 1,
"unit": "",
"name": "Mango, peeled and diced"
},
{
"amount": 1,
"unit": "",
"name": "Avocado, sliced"
},
{
"amount": null,
"unit": "",
"name": "Crispy fried onions (for topping)"
},
{
"amount": null,
"unit": "",
"name": "Black sesame seeds (for topping)"
}
],
"steps": [
{
"title": "Prepare Pickled Red Onions",
"instruction": "Thinly slice the red onion. In a jar, combine red wine vinegar, 1 tablespoon of sugar, and water. Stir until the sugar dissolves. Add the sliced red onions to the jar, seal, and set aside to pickle while you prepare the other ingredients."
},
{
"title": "Cook and Season Sushi Rice",
"instruction": "Cook sushi rice according to package instructions. Once cooked, transfer it to a bowl and gently mix in the apple cider vinegar and 1 teaspoon of sugar. Set aside."
},
{
"title": "Prepare and Air Fry Salmon",
"instruction": "Cut the salmon fillet into 1-inch cubes. In a mixing bowl, combine the salmon cubes with spicy mayo, chili garlic oil, salt, and freshly ground black pepper. Toss until the salmon is evenly coated. Transfer the salmon to an air fryer basket and air fry at 400°F (200°C) for 10-12 minutes, or until crispy and cooked through, shaking the basket halfway."
},
{
"title": "Prepare Fresh Vegetables and Fruit",
"instruction": "While the salmon cooks, dice the cucumbers, shred the red cabbage, chop the green onions, peel and dice the mango, and slice the avocado."
},
{
"title": "Assemble Poke Bowls",
"instruction": "In two serving bowls, create a base with the seasoned sushi rice. Arrange the cooked salmon, pickled red onions, diced cucumbers, shredded red cabbage, chopped green onions, diced mango, and sliced avocado around the rice."
},
{
"title": "Garnish and Serve",
"instruction": "Drizzle with additional spicy mayo and chili garlic oil if desired. Garnish with crispy fried onions and black sesame seeds. Serve immediately and enjoy!"
}
],
"notes": "For best results, let the red onions pickle for at least 30 minutes, or even a few hours, to develop their flavor. Adjust the amount of spicy mayo and chili garlic oil to your preferred spice level."
}

184
server.js
View File

@@ -2,21 +2,40 @@ import express from 'express';
import path from 'path';
import { fileURLToPath } from 'url';
import dotenv from 'dotenv';
import fs from 'fs/promises';
import { existsSync } from 'fs';
import crypto from 'crypto';
import { execFile } from 'child_process';
import util from 'util';
import { GoogleGenerativeAI } from '@google/generative-ai';
import { GoogleAIFileManager } from '@google/generative-ai/server';
const execFilePromise = util.promisify(execFile);
dotenv.config();
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const RECIPES_DIR = path.join(__dirname, 'recipes');
const TEMP_DIR = path.join(__dirname, 'temp_ingest');
// Ensure directories exist
async function ensureDirs() {
try { await fs.access(RECIPES_DIR); } catch { await fs.mkdir(RECIPES_DIR); }
try { await fs.access(TEMP_DIR); } catch { await fs.mkdir(TEMP_DIR); }
}
ensureDirs();
const app = express();
app.use(express.json());
// Serve static frontend files from Vite build
app.use(express.static(path.join(__dirname, 'dist')));
// Secure credentials from environment variables, or fallback to defaults
const VALID_USER = process.env.APP_USERNAME || 'admin';
const VALID_PASS = process.env.APP_PASSWORD || 'souschef';
// Secure credentials strictly from environment variables
const VALID_USER = process.env.APP_USERNAME;
const VALID_PASS = process.env.APP_PASSWORD;
const GEMINI_API_KEY = process.env.VITE_GEMINI_API_KEY || process.env.GEMINI_API_KEY;
app.post('/api/login', (req, res) => {
@@ -62,12 +81,169 @@ app.post('/api/generate', async (req, res) => {
}
});
app.post('/api/ingest', async (req, res) => {
const { username, password, url } = req.body;
if (username !== VALID_USER || password !== VALID_PASS) {
return res.status(401).json({ error: 'Unauthorized' });
}
if (!GEMINI_API_KEY) {
return res.status(500).json({ error: 'API key not configured on server' });
}
if (!url) return res.status(400).json({ error: 'Reel URL is required' });
const jobId = crypto.randomBytes(8).toString('hex');
const jobDir = path.join(TEMP_DIR, jobId);
try {
await fs.mkdir(jobDir, { recursive: true });
console.log(`[Ingest ${jobId}] Step 1: Running yt-dlp on ${url}`);
const videoOutputTemplate = path.join(jobDir, 'video.%(ext)s');
const descFile = path.join(jobDir, 'video.description');
await execFilePromise('yt-dlp', [
'-f', 'best[ext=mp4]/best',
'--no-playlist',
'-o', videoOutputTemplate,
'--write-description',
url
]);
const dirFiles = await fs.readdir(jobDir);
const videoFile = dirFiles.find(f => /^video\.(mp4|webm|mkv|mov|avi)$/i.test(f));
if (!videoFile) throw new Error('yt-dlp did not produce a video file');
const videoPath = path.join(jobDir, videoFile);
const ext = path.extname(videoFile).slice(1).toLowerCase();
const MIME_MAP = { mp4: 'video/mp4', webm: 'video/webm', mkv: 'video/x-matroska', mov: 'video/quicktime', avi: 'video/x-msvideo' };
const videoMimeType = MIME_MAP[ext] || 'video/mp4';
let captionText = '';
if (existsSync(descFile)) captionText = await fs.readFile(descFile, 'utf-8');
console.log(`[Ingest ${jobId}] Step 2: Uploading file to Gemini (${videoFile})...`);
const fileManager = new GoogleAIFileManager(GEMINI_API_KEY);
const uploadResult = await fileManager.uploadFile(videoPath, {
mimeType: videoMimeType,
displayName: `Reel Ingest ${jobId}`,
});
let file = await fileManager.getFile(uploadResult.file.name);
console.log(`[Ingest ${jobId}] Uploaded ${file.displayName} as: ${file.uri}`);
// Wait for the video processing to complete on Google's side
while (file.state === "PROCESSING") {
process.stdout.write(".");
await new Promise((resolve) => setTimeout(resolve, 5000));
file = await fileManager.getFile(uploadResult.file.name);
}
if (file.state === "FAILED") {
throw new Error("Video processing failed inside Gemini.");
}
console.log(`\n[Ingest ${jobId}] Step 3: Sending prompt to Gemini...`);
const genAI = new GoogleGenerativeAI(GEMINI_API_KEY);
const model = genAI.getGenerativeModel({ model: "gemini-2.5-flash" });
const systemPrompt = "You are a professional chef. You consume video, audio, and captions from a recipe reel, and output structured JSON. Required keys: title (string), description (string), servings (number), prepTime (string), cookTime (string), ingredients (array of {amount, unit, name} objects), steps (array of {title, instruction} objects), and notes (string).";
const result = await model.generateContent({
contents: [
{
role: 'user', parts: [
{ fileData: { fileUri: uploadResult.file.uri, mimeType: uploadResult.file.mimeType } },
{ text: "Extract the recipe (ingredients, measurements, and steps) into the required JSON format." },
...(captionText.trim() ? [{ text: `Original Social Media Caption:\n"${captionText}"\n` }] : [])
]
}
],
systemInstruction: { role: 'system', parts: [{ text: systemPrompt }] },
generationConfig: { responseMimeType: "application/json", temperature: 0.2 }
});
const recipeRaw = JSON.parse(result.response.text());
// Save securely
const recipeId = crypto.randomBytes(8).toString('hex');
await fs.writeFile(path.join(RECIPES_DIR, `${recipeId}.json`), JSON.stringify(recipeRaw, null, 2));
// Cleanup local temp dir and remote API file
await fs.rm(jobDir, { recursive: true, force: true });
await fileManager.deleteFile(uploadResult.file.name);
res.json({ id: recipeId, recipe: recipeRaw });
} catch (err) {
console.error(`[Ingest ${jobId}] Failed`, err);
// Try to cleanup jobDir just in case
try { await fs.rm(jobDir, { recursive: true, force: true }); } catch { }
res.status(500).json({ error: err.message });
}
});
app.post('/api/recipes', async (req, res) => {
const { username, password, recipe } = req.body;
if (username !== VALID_USER || password !== VALID_PASS) {
return res.status(401).json({ error: 'Unauthorized' });
}
try {
const id = crypto.randomBytes(8).toString('hex');
await fs.writeFile(path.join(RECIPES_DIR, `${id}.json`), JSON.stringify(recipe));
res.json({ id });
} catch (err) {
res.status(500).json({ error: 'Failed to save recipe' });
}
});
app.get('/api/recipes', async (req, res) => {
// Pass auth via headers
const username = req.headers['x-username'];
const password = req.headers['x-password'];
if (username !== VALID_USER || password !== VALID_PASS) {
return res.status(401).json({ error: 'Unauthorized' });
}
try {
const files = await fs.readdir(RECIPES_DIR);
const recipes = [];
for (const file of files) {
if (file.endsWith('.json')) {
const content = await fs.readFile(path.join(RECIPES_DIR, file), 'utf-8');
const parsed = JSON.parse(content);
parsed.id = file.replace('.json', '');
recipes.push(parsed);
}
}
res.json(recipes);
} catch (err) {
res.status(500).json({ error: 'Failed to fetch recipes' });
}
});
app.get('/api/recipes/:id', async (req, res) => {
// Public route - anyone with the ID can view
try {
const recipeId = req.params.id.replace(/[^a-z0-9]/gi, ''); // basic sanitization
const content = await fs.readFile(path.join(RECIPES_DIR, `${recipeId}.json`), 'utf-8');
res.json(JSON.parse(content));
} catch (err) {
res.status(404).json({ error: 'Recipe not found' });
}
});
// For any other route, send the frontend
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, 'dist', 'index.html'));
});
const PORT = process.env.PORT || 80;
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Secure proxy server running on port ${PORT}`);
});

9
vite.config.js Normal file
View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vite';
export default defineConfig({
server: {
proxy: {
'/api': 'http://localhost:3000'
}
}
});