Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,20 @@ Runnable demos built on [Eden AI](https://www.edenai.co): one API key and one bi
| Demo | What it shows | Providers | Eden AI endpoints |
|---|---|---|---|
| [voice-web-research](voice-web-research/) | Ask a question out loud and get a cited, spoken answer from the live web | Gradium (speech-to-text, text-to-speech), Nebius (LLM), Tavily (search) | `/v3/upload`, `/v3/universal-ai` (+ async), `/v3/chat/completions` |
| [news-to-video](news-to-video/) | Turns a topic into a narrated 9:16 news clip built from today's web, in 30–45 seconds | Tavily (search), Nebius (LLM), Pruna (video), Gradium (voice) | `/v3/universal-ai` (+ async), `/v3/chat/completions` (structured output) |
| [upstream-watch](upstream-watch/) | A daily GitHub Action where the web tells a repo its dependencies are stale: it opens cited bump PRs, plus issues for breaking changes | Tavily (search), Nebius (LLM) | `/v3/universal-ai`, `/v3/chat/completions` (structured output) |

## Providers in this cookbook

Each demo picks the provider that's best at its step. Each demo's README has a **Why these providers** section with the numbers we measured.

| Provider | What it does here | Why it's a great fit | Demos |
|---|---|---|---|
| **Tavily** | Web search | A search API built for AI agents. It returns cleaned, ranked page content that an LLM can cite straight away, in about a second, for $0.008 per search | all three |
| **Nebius** | LLMs: Qwen3-235B, gpt-oss-120b | Nebius Token Factory serves open-weight models behind an OpenAI-compatible API, with reliable tool calling and strict JSON-schema output, for under $0.001 per call in these demos | all three |
| **Gradium** | Speech-to-text and text-to-speech | A voice specialist built by the team behind the Kyutai research lab, listed as EU-hosted on Eden AI. It gives accurate transcripts of browser recordings and a natural reading voice | voice-web-research, news-to-video |
| **Pruna** | Video generation | Built for speed. A 5 s vertical clip with its own soundtrack costs $0.10, so three render in parallel within a half-minute pipeline. Pruna reports that its newest model, P-Video-2-Pro, ranks #2 overall on the Design Arena video leaderboard | news-to-video |

## How the cookbook is organized

- **One folder per demo**, named after what it does. Providers are listed in the table, not used as folders, because most demos mix several of them and let you swap them.
Expand Down
1 change: 1 addition & 0 deletions news-to-video/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
EDENAI_API_KEY=sk-eden-...
3 changes: 3 additions & 0 deletions news-to-video/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.env
out/
__pycache__/
124 changes: 124 additions & 0 deletions news-to-video/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# News to video

**Give it a topic. 30–45 seconds later you have a narrated 9:16 news clip made from today's web.**

```
topic → Tavily web search → Nebius LLM writes a headline, a narration and 3 shots
→ Pruna renders the 3 clips in parallel, while Gradium reads the narration
→ ffmpeg joins them into a vertical video with captions
```

| At a glance | |
|---|---|
| Providers | Tavily (web search), Nebius (script), Pruna (video), Gradium (narration) |
| One video | 30–45 s end to end; about $0.32; 720×1280, 15–18 s, with captions and an *AI-generated* label |
| Runs as | A local web page that streams each step live, or a command-line tool |
| Eden AI endpoints | `/v3/universal-ai` (+ async), `/v3/chat/completions` (JSON schema) |

## Why these providers

| Step | Provider, via Eden AI | Why it's a great fit here |
|---|---|---|
| Search | **Tavily** `web/search/tavily` | A search API built for AI agents. It returns cleaned, ranked page content, so the script is written from today's articles rather than the model's memory. That was 6 results in 0.5–1.9 s for $0.008. |
| Script | **Nebius** `nebius/openai/gpt-oss-120b` | Strict JSON-schema output means the headline, narration and 3 shots always come back parseable. It's an open-weight model on Nebius Token Factory: about 3 s and under $0.001 per script. That's about 3× faster than Qwen for this task, with more specific captions ("Nscale $2B raise" rather than "Funding Surge"). |
| Video | **Pruna** `video/generation_async/pruna/p-video` | Speed is Pruna's whole focus: it builds optimized, faster models. A 5 s, 704×1280 clip with its own soundtrack costs $0.10 and took 13–45 s through Eden AI, so three in parallel keep the whole video to about half a minute. Pruna reports that its newest model, P-Video-2-Pro, ranks #2 overall on the Design Arena video leaderboard while sitting on its quality-versus-speed frontier (about 2 s for a 5 s clip in Speed mode). It isn't on Eden AI yet; when it is, it's one `--video-model` flag away. |
| Narration | **Gradium** `audio/tts/gradium` | A natural newsreader voice from the Paris voice company behind the Kyutai lab's research, listed as EU-hosted on Eden AI. The 34-word narration takes 6–8 s and about $0.012. It finishes while the clips are still rendering, so it's never what you wait for. |

**Why Eden AI ties it together:**
- **One key** for four providers, and one bill.
- **One video endpoint:** a different video model is a one-string change.
- **Fallbacks** for search (Linkup) and the LLM (Qwen).
- **Visible cost:** every call is tagged `demo=news-to-video` and shown in the cost table with its time.

Each Pruna clip comes with its own soundtrack, which plays at 20% under the narration.

## Run it

Requires Python 3.10+ and [ffmpeg](https://ffmpeg.org) (`brew install ffmpeg` or `sudo apt install ffmpeg`).

```bash
cd news-to-video
pip install -r requirements.txt
cp .env.example .env # then paste your Eden AI key
```

**Web view:** run `uvicorn app:app` and open http://localhost:8000. Type a topic and press **Generate**. The page shows:
- each step turning green as it finishes, with a running timer;
- the headline and narration as soon as they're written;
- the three Pruna clips playing in their slots the moment each one lands;
- the final video, with its sources and the time and cost of every call.

**Command line:** `python news_to_video.py "European AI startups"`. A real run on 2026-09-29:

```
Searching the web for: European AI startups
Writing the script from 6 results
"European AI Funding Soars 2026"
narration (35 words): European AI startups raised $23 billion in the first half of 2026, a 130 percent jump, highlighted by Nscale’s $2 billion Series C and Sweden’s Klang securing SEK 15 million to expand its conversational AI.
Rendering 3 clips with video/generation_async/pruna/p-video while audio/tts/gradium reads the narration
narration ready (7.6 s)
clip 3 ready (13.5 s)
clip 1 ready (16.1 s)
clip 2 ready (21.6 s)
Stitching with ffmpeg

web search web/search/tavily 1.6 s $0.0080
script nebius/openai/gpt-oss-120b 2.8 s $0.0009
narration audio/tts/gradium 7.6 s $0.0119
clip 3 video/generation_async/pruna/p-video 13.5 s $0.1000
clip 1 video/generation_async/pruna/p-video 16.1 s $0.1000
clip 2 video/generation_async/pruna/p-video 21.6 s $0.1000
stitch ffmpeg 3.2 s $0.0000

Done in 29 s for $0.3208 via Eden AI
Video: out/2026-09-29-094730-european-ai-startups/news.mp4
Script: out/2026-09-29-094730-european-ai-startups/script.md
```

Each run gets its own folder, `out/<date>-<time>-<topic>/`, containing:
- **`news.mp4`:** 720×1280, 24 fps, H.264 plus AAC, 15–18 s, with an *AI-generated* label on every frame. This run's captions were "AI compute surge", "Nscale $2B raise" and "Klang expands".
- **`script.md`:** the headline, the narration, the shot prompts, the sources, and the timing and cost table.
- **The raw pieces:** `shot1-3.mp4` and the narration audio.

## Demo script (about 1 minute)

1. Open the web view full screen and ask the audience for a topic.
2. Press **Generate**. Talk over the pipeline as it lights up: *Search* (Tavily), *Script* (Nebius), then **three Pruna clips rendering at the same time**, each appearing in its slot as it lands, while *Narration* (Gradium) finishes alongside.
3. When the timer stops, at about 30–45 s, play the final video. Point at the cost table: four providers, one key, about $0.32.

## How it works

| File | What it does |
|---|---|
| `news_to_video.py` | `make_video()` runs the pipeline: search → script (structured JSON) → 3 clips and the narration in parallel → `ffmpeg` stitch → `script.md`. It reports each step as it finishes; the CLI prints these updates |
| `app.py` | Web view: `POST /api/run` streams those step updates to the page as one JSON object per line, and `/out/` serves the clips and the video |
| `static/index.html` | The page: topic box, a live five-step pipeline with a timer, 3 clip slots, the final 9:16 video and the cost table |
| `edenai.py` | The Eden AI calls: `web/search/tavily` (falls back to linkup), `/v3/chat/completions` with a JSON schema, `video/generation_async/pruna/p-video` (async job plus polling), `audio/tts/gradium`, and a plain download of the results |

The details that keep it reliable:
- **One search:** its results go to the LLM as untrusted data, and the LLM may only state facts found in them.
- **No real people:** shot prompts can't contain text, logos, real faces or named people. News is about real people, and a video model shouldn't invent their likeness.
- **Narration fits the clips:** it's capped at 34 words, about 14 s at Gradium's pace, by keeping whole sentences. If it still runs long, the last frame holds until it ends.
- **Captions fit the frame:** the font shrinks for long captions, and `ffmpeg` reads them from files, so `%`, `:` and quotes need no escaping.
- **Any video model:** if one returns clips without sound, the video keeps just the narration.
- **No accidental repeat runs:** the page reads the stream with `fetch`, not `EventSource`. `EventSource` reconnects when a stream ends, which here would silently start another billed run. A run finishes even if the page is closed, because its clips are already paid for.

## Options

| Flag | Default | Notes |
|---|---|---|
| `--video-model` | `video/generation_async/pruna/p-video` | `.../pruna/p-video-2` has higher quality but was much slower in our tests (about 96 s for one clip) |
| `--llm` | `nebius/openai/gpt-oss-120b` | Falls back to `nebius/Qwen/Qwen3-235B-A22B-Instruct-2507`. Qwen took about 9 s here against about 3 s for gpt-oss |
| `--tts` | `audio/tts/gradium` | Any Eden AI TTS, e.g. `audio/tts/deepgram/aura-2` or `audio/tts/elevenlabs` |
| `--no-captions` | captions on | |
| `--out` | `out/` | One folder per run, named `<date>-<time>-<topic>` |

## Cost and timing

About **$0.32 per video**. Almost all of it is the three 5-second Pruna clips ($0.02 per second, so $0.10 each); the search, LLM and narration add about $0.02 together. End to end it takes **30–45 s**. The very first run took 64 s, before the script writer was switched from Qwen to gpt-oss. The clips dominate, because Pruna's latency through Eden AI varied from about 13 s to 45 s per clip across our runs. The three render in parallel, so a run waits for the slowest one.

**P-Video-2-Pro**, Pruna's newest model (a 5 s clip in about 2 s in Speed mode), isn't on Eden AI yet. When it is, pass it as `--video-model` and the clip step should shrink to a few seconds.

## Responsible use

Every frame is labelled *AI-generated*, and `script.md` lists the sources the narration is based on. It's a demo: check the facts before publishing anything it makes. Two things we saw in real runs: the narration can compress a detail wrongly (one said "$0.02 per 480p clip" where the source said per second), and video models sometimes draw made-up text despite the prompt.
58 changes: 58 additions & 0 deletions news-to-video/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"""Web view for news-to-video: type a topic, watch the 3 Pruna clips arrive, then play the narrated result."""
import asyncio
import json
import shutil
from pathlib import Path

from fastapi import FastAPI, Form, HTTPException
from fastapi.responses import FileResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles

import edenai
from news_to_video import VIDEO, make_video

OUT = Path("out")
OUT.mkdir(exist_ok=True)
MODELS = [VIDEO, "video/generation_async/pruna/p-video-2"] # p-video-2: higher quality, much slower
RUNS = set() # keep a reference to running pipelines

print(f"\nEden AI base URL: {edenai.BASE}" + ("" if edenai.KEY else " ! EDENAI_API_KEY is not set: copy .env.example to .env")
+ ("" if shutil.which("ffmpeg") else " ! ffmpeg is not installed") + "\n")

app = FastAPI()
app.mount("/out", StaticFiles(directory=OUT), name="out") # the clips and the final video


@app.get("/")
def index():
return FileResponse("static/index.html")


@app.get("/api/models")
def models():
return MODELS


@app.post("/api/run")
async def run(topic: str = Form(...), video_model: str = Form(VIDEO)):
"""Make a video, streaming one JSON event per line as each step finishes (see make_video)."""
if video_model not in MODELS or not topic.strip():
raise HTTPException(400, "unknown video model or empty topic")
events: asyncio.Queue = asyncio.Queue()

async def pipeline():
try:
await make_video(topic.strip(), OUT, video_model=video_model, emit=events.put_nowait)
except Exception as e: # shown in the page instead of a broken stream
events.put_nowait({"event": "error", "message": str(e)})
events.put_nowait(None)

task = asyncio.create_task(pipeline()) # runs to the end even if the page is closed: the clips are already paid for
RUNS.add(task)
task.add_done_callback(RUNS.discard)

async def stream():
while (event := await events.get()) is not None:
yield json.dumps(event) + "\n"

return StreamingResponse(stream(), media_type="application/x-ndjson")
64 changes: 64 additions & 0 deletions news-to-video/edenai.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""The Eden AI calls behind news-to-video: web search, an LLM, video generation and text-to-speech. One key."""
import asyncio
import os
import time

import httpx

try: # a .env file for local runs
from dotenv import load_dotenv
load_dotenv()
except ImportError:
pass

BASE = os.getenv("EDENAI_BASE_URL", "https://api.edenai.run/v3")
KEY = os.getenv("EDENAI_API_KEY", "")
TAGS = {"demo": "news-to-video"} # cost of this demo, split out in the Eden AI dashboard
client = httpx.AsyncClient(base_url=BASE, timeout=120, headers={"Authorization": f"Bearer {KEY or 'missing-key'}"})


async def call(method: str, path: str, **kwargs) -> dict:
r = await client.request(method, path, **kwargs)
if r.status_code >= 400:
raise RuntimeError(f"Eden AI {path}: HTTP {r.status_code}: {r.text[:300]}")
body = r.json()
if body.get("status") == "fail": # provider failures come back as HTTP 200
error = body.get("error") or {}
raise RuntimeError(f"{body.get('provider')} failed: {error.get('message', error) if isinstance(error, dict) else error}")
return body


async def search(query: str) -> dict:
return await call("POST", "/universal-ai", json={"model": "web/search/tavily", "fallbacks": ["web/search/linkup"],
"input": {"query": query, "max_results": 6}, "tags": TAGS})


async def chat(messages: list, model: str, fallbacks: list, schema: dict) -> dict:
return await call("POST", "/chat/completions", json={
"model": model, "fallbacks": fallbacks, "messages": messages, "temperature": 0.4, "tags": TAGS,
"response_format": {"type": "json_schema", "json_schema": {"name": "script", "strict": True, "schema": schema}}})


async def video(prompt: str, model: str, dimension: str, seconds: int, timeout: int = 300) -> dict:
"""Start a video job and poll it until it's done (video generation is async on Eden AI)."""
job = await call("POST", "/universal-ai/async", json={
"model": model, "input": {"text": prompt, "duration": seconds, "dimension": dimension}, "tags": TAGS})
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
body = await call("GET", f"/universal-ai/async/{job['public_id']}")
if body["status"] == "success":
return body
await asyncio.sleep(2)
raise RuntimeError(f"video job {job['public_id']} still running after {timeout}s")


async def speak(text: str, model: str) -> dict:
audio_format = "wav" if model.startswith("audio/tts/gradium") else "mp3" # Gradium has no mp3
return await call("POST", "/universal-ai", json={"model": model, "input": {"text": text, "audio_format": audio_format}, "tags": TAGS})


async def download(url: str) -> bytes:
async with httpx.AsyncClient(timeout=120) as plain: # no Authorization header: these are CDN links
r = await plain.get(url)
r.raise_for_status()
return r.content
Loading
Loading