Sr. Content Developer at Microsoft, working remotely in PA, TechBash conference organizer, former Microsoft MVP, Husband, Dad and Geek.
159389 stories
·
33 followers

How Do You Build a Social Media Feed? (System Design)

1 Share

Every social app has a home feed, and it looks like the easiest feature in the product.

Fetch the recent posts from every account the user follows, merge them, sort by time, return the first page. You could ship it in an afternoon.

Feeds are a classic system design problem because the obvious version does the work at read time, and the version that survives production does it at write time. Let's build one and watch where each version breaks.

Fan-Out on Read

The naive feed computes everything at read time: fan-out on read.

The feed is a query. When a user opens the app, the Feed API runs the merge live, against the posts of every account they follow. A user following 800 accounts turns one page view into an 800-way merge that runs before anything renders, and pull-to-refresh throws the result away and runs it again.

A client requests its feed, and the Feed API fans out reads to the posts of every followed author in the post store, merging and sorting the results on every refresh

To be fair to the naive design: with a composite index on (author_id, created_at), that merge is a single query that returns in milliseconds, and it stays fine well past the point most products ever reach. If your users follow a few hundred accounts and you serve hundreds of feed reads per second, ship the query and move on.

The problem is the ratio. Twitter published its numbers years ago: roughly 300,000 home timeline reads per second against about 5,000 new tweets. Sixty reads for every write is the ratio that justifies moving the work to the write side.

The Single-Writer Rule

Before fixing reads, the write path needs one property: a single writer.

Public traffic enters through an API gateway, and only the Post API writes the post store. The gateway already handles authentication. What the single writer buys is one transactional boundary: committing a post and recording its event happen atomically, in commit order.

Split posting across two services and you get two event streams with no shared order, where a delete can race ahead of the create it refers to. It also gives your invariants one home.

A client publishes through an API gateway to the Post API, which is the only writer of the post store, while a blocked red path shows that direct client writes are not allowed

Fan-out on write depends on this: every committed post produces exactly one event.

Fan-Out on Write

The first fix most teams reach for is caching the merged page per user with a short TTL, and it does absorb repeat refreshes. But a feed cache entry serves exactly one user, so the hit rate is only as good as how often that user reloads within the window, and every expiry brings back the full merge. Fan-out on write is that cache with the timer removed: kept correct by writes instead of rebuilt on expiry, and populated for every follower whether or not they ever read.

When an author publishes a post:

  1. The Post API commits the post to the post store.
  2. It publishes a small immutable event to a topic: author id, post id, timestamp.
  3. Fan-out workers consume the event, expand the author into their follower list, and append the post id to each follower's timeline: a capped list of recent post ids in a cache.

The Feed API serves a feed with one timeline lookup and a batched fetch to hydrate the ids into posts. Scroll past the cap and the feed falls back to the query path. Almost nobody does.

An author

Five decisions make this hold up in production:

  • Store ids, not posts. Twitter capped home timelines at roughly 800 entries, and 800 ids is about 10 KB per user: 100 million users fit in a terabyte of cache. Full 2 KB post documents would need 160 TB.
  • Enforce policy at hydration. Deleted posts drop out as hydration misses, block filters run on every request, and an unfollow needs no cleanup: the author's entries age out past the cap.
  • The projection is disposable. A lost timeline is rebuilt from the post store with the naive query, once per cold user. The catch: a dead cache node sends every user on it cold at once, so cap rebuild concurrency or the post store inherits a read storm.
  • Publish the event if and only if the post commits. Publish before commit and you announce posts that don't exist; publish after and a crash can lose the event. The Outbox pattern closes both gaps.
  • Chunk the fan-out. Competing consumers parallelize across posts, not within one, so a big expansion is split into follower-range sub-jobs that workers share. Appends are idempotent, keyed by post id, so a redelivered chunk touches nothing already written.

The tradeoff: eventual consistency. The first user to notice is the author: they publish, refresh, and their own post is missing.

The fix is read-your-own-writes at the feed edge: the Feed API merges the reader's own recent posts in at read time. Followers seeing a post a few seconds late is the part nobody notices.

Write Amplification

Fan-out on write has a multiplier hiding in step 3: every publish costs one timeline write per follower.

For an account with 300 followers, that's 300 small cache appends. Cheap.

But follower counts follow a power-law distribution. An account with 50 million followers publishes once, and the workers now owe the cache 50 million writes.

The 60-to-1 ratio justified paying per write because writes were rare and each append was cheap. A post that becomes 50 million appends, most of them into timelines nobody will open, breaks both premises. Even the expansion is heavy: 50 million follower rows paged out of the graph store just to know where to write.

While the workers grind through it, two things go wrong for everyone else:

  • Consumer lag. The celebrity's chunks tie up worker capacity for minutes, the backlog ages, and every feed behind it goes stale.
  • Cache churn. Allocating timeline entries for tens of millions of mostly inactive followers pressures the cache, and warm timelines are evicted to make room.
A celebrity post at the head of the topic ties up the fan-out workers with 50 million cache appends, while the backlog of ordinary posts behind it grows and warm feeds are evicted from the timeline cache

Two mitigations come before a redesign. Fan out only to followers who were active recently, and rebuild dormant timelines on their next visit (the disposable projection already paid for that path). Route the biggest accounts to their own queue, so ordinary posts stop waiting behind them.

What neither does is shrink the work: the biggest accounts still owe millions of writes per post, and the cache pressure lands regardless.

Hybrid Fan-Out

No single strategy serves both ends of a power-law distribution.

Ordinary authors keep fan-out on write. Bounded follower sets make write-time work cheap, and reads stay one lookup.

Celebrity authors switch to a narrow form of fan-out on read. Their posts are appended to a compact celebrity index, keyed by author, and publishing becomes one write regardless of follower count.

At read time, the Feed API merges the user's materialized timeline with recent candidates from the celebrity indexes they follow, deduplicates, and hydrates. A follower-count threshold or cost model decides which path an author uses.

Ordinary posts flow through the topic to fan-out workers that write follower timelines, celebrity posts land in a compact celebrity index with one write per post, and the Feed API merges both sources at read time before returning the feed to the reader

Twitter described this same architecture publicly years ago: home timelines materialized in Redis by fan-out workers, with the highest-follower accounts merged in at read time.

The tradeoffs:

  • Reads get more complex and less predictable. A user following many celebrity accounts pays multiple index reads and a bigger merge on every page.
  • Pagination needs a cursor per source. Where the merge stopped in the timeline, plus the last id consumed from each celebrity index. Resuming each source from its own position is what keeps a mid-scroll post from duplicating or vanishing.
  • The threshold flaps. Promotion can put a post in both sources, which the merge deduplicates by post id. Demotion is the dangerous direction: posts that live only in the index vanish from feeds unless you backfill them into follower timelines or keep merging the demoted index for a grace window.

Ranked Feeds

Everything so far assumes reverse-chronological order, which is the feed that Twitter talk describes. None of the big networks ship that as the default anymore.

A ranked feed keeps the same plumbing and adds a funnel on the read path:

  1. Candidate generation. The materialized timeline and celebrity indexes become candidate sources, joined by out-of-network sources: posts from accounts you don't follow, retrieved by embedding similarity and graph signals.
  2. Light ranking. A cheap model trims thousands of candidates to a few hundred, because the good model is too expensive to run on everything.
  3. Heavy ranking. A neural model scores each surviving post by predicting engagement probabilities (like, reply, repost, dwell time) and combining them into one weighted score.
  4. Re-ranking. Product rules run last: author diversity, integrity filters, blocked-content removal, ad slots.
The timeline cache, celebrity index, and out-of-network sources feed candidate generation, a light ranker trims thousands of posts to a few hundred, a heavy ranker scores them by predicted engagement, and re-ranking rules produce the final page

When Twitter open-sourced its recommendation algorithm in 2023, this was the shape: roughly half the candidates in-network, half out-of-network, funneled through a light ranker into a neural "heavy ranker" that predicts engagement.

None of it replaces the fan-out machinery. The timeline you materialized is still there; it became one candidate source among several, and the merge became a scoring stage.

Operating the Pipeline

Most of this system is asynchronous, so operating it means watching lag rather than error rates.

  • Consumer lag. The age of the oldest unprocessed publish event. The first number to alarm on.
  • Fan-out writes per post, by author. The number a per-author throttle acts on. One author dominating worker time means the celebrity threshold is set wrong.
  • Commit-to-visible latency. From transaction commit until the post shows up in follower timelines, tracked at a high percentile. The first follower gets it in milliseconds; the 50 millionth is the number that matters.
  • Feed cache hit rate. A dropping hit rate is the early symptom of cache churn.
The pipeline from Post API through topic, workers, and timeline cache to the Feed API, annotated with the lag metric each stage exposes and a commit-to-visible latency span across the whole path

Summary

The whole progression:

  • Fan-out on read: the feed is a query; fine until the read-to-write ratio makes it the most expensive path in the product.
  • Single writer: one transactional boundary, so every committed post produces exactly one event.
  • Fan-out on write: materialize timelines at publish time; reads become one lookup, and the projection stays disposable.
  • Write amplification: one celebrity post becomes 50 million writes, and everyone else pays.
  • Hybrid fan-out: ordinary authors fan out on write; celebrity posts sit in a compact index that the Feed API merges in at read time.

If you'd rather design this than read about it, I just launched System Design Studio on Katabench. You wire components on a canvas, and a grader checks your topology, then explains what it can and cannot prove.

The three challenges behind this article (the write path, timeline fan-out, celebrity fan-out) are free. Start with the celebrity fan-out, and hit reply if the grader disagrees with you.

Thanks for reading.

And stay awesome!




Read the whole story
alvinashcraft
1 hour ago
reply
Pennsylvania, USA
Share this story
Delete

How Claude’s text watermark works

1 Share
How Claude’s text watermark works
Read the whole story
alvinashcraft
1 hour ago
reply
Pennsylvania, USA
Share this story
Delete

What’s !important #17: Custom Highlight API, CSS Navigation Matching, Fixing text-stroke, and More

1 Share

Plus, how to style skeleton UIs, how to enable diagonal scrolling, how images can overflow themselves, and yet, still more. Basically, how to do a lot of really cool (CSS) stuff.


What’s !important #17: Custom Highlight API, CSS Navigation Matching, Fixing text-stroke, and More originally handwritten and published with love on CSS-Tricks. You should really get the newsletter as well.

Read the whole story
alvinashcraft
2 hours ago
reply
Pennsylvania, USA
Share this story
Delete

Alibaba’s new model promises Opus 4.6-level performance on your laptop

1 Share

Alibaba recently made the open weights of its 2.4 trillion parameter Qwen3.8 model available. That’s a massive model, and its benchmarks put it in direct competition with closed frontier models from American labs. But what’s maybe even more interesting is that on Friday, Alibaba also made a dense 27 billion parameter version of Qwen 3.8 available under the Apache 2.0 license.

That’s a model that you could run locally on a well-specced Macbook Pro or Mac Studio, for example — and it might be worth doing so, because according to Alibaba’s benchmarks, the model’s performance is in the same league as Anthropic’s Opus 4.6 running at its Max setting.

On quite a few benchmarks, it even outperforms Anthropic’s former flagship model, which was state-of-the-art six months ago when it launched in February, especially when it comes to some computer use, coding and knowledge work tests.

Add to that the fact that the model also has vision capabilities — including videos — and this becomes a compelling option for local usage (assuming you have the hardware to support it).

Caveats

As usual, the caveat here is that benchmarks don’t always measure how well a model performs in the real world, and for agentic use cases, the harness they run in can be as important as the model itself. Early reports say the model tends to overthink, for example.

It also looks like Alibaba’s benchmarks describe the original checkpoint, not the quantized versions most local users will run. Quantization may make a model practical on consumer hardware, but there is always a quality tradeoff.

Still, overall, these are impressive results for a model that you could run locally.

Credit: Alibaba.

Alibaba notes that the model significantly outperforms the previous-generation Qwen 3.7-Plus, especially in coding and knowledge work tasks, and some of the performance jumps are quite impressive, including a step up on the DeepSWE agentic coding benchmarks from 14.2 points to 42.2. 

That’s still behind the current frontier (and other open models like the newly released GLM-5.3), but even Google’s mid-tier Gemini 3.6 Flash only got to 49% on this test, and you’re not running that model on your laptop anytime soon.

Credit: Alibaba.

Alibaba also compares the model with Meta’s Muse Glimmer-30B, another recently released local model in roughly the same size class. Qwen3.8-27B leads it on every test for which Alibaba reports scores for both models.

Glimmer, it seems, was the right name for that model.

The hardware you need to run Qwen 3.8 27B locally

Speed is another issue, of course. As of now, Alibaba hasn’t released a smaller Qwen3.8 27B mixture-of-experts sibling. Such a model could activate fewer parameters per token and run faster than the dense 27B release. It previously did so for the Qwen 3.6-35B model, for example.

The unquantized Qwen3.8-27B repository is 55.6GB, before accounting for the inference runtime and context cache. That’s not what most people will run.

The good news for Mac users is that community-created MLX conversions for Apple silicon are already available. The 4-bit version is about 16.1GB, while the 8-bit version is 29.5GB. That makes a Mac with 32GB of unified memory a resonable platform for running a 4-bit version at a moderate context length. If you have it, 48GB or 64GB would obviously provides considerably more room for higher precision or longer prompts.

By default, the model supports 262,000 tokens of context, which can be extended (and will be by Alibaba for its hosted production version) to 1 million tokens by using the YaRN method. That doesn’t mean you can use that full context window on your desktop (the key-value cache consumes more memory as the prompt grows).

The post Alibaba’s new model promises Opus 4.6-level performance on your laptop appeared first on The New Stack.

Read the whole story
alvinashcraft
2 hours ago
reply
Pennsylvania, USA
Share this story
Delete

[Dev Log][Python] Create short videos from photos and clips with Gemini 3.7 Flash: ReelCraft

1 Share

reelcraft-logo

Preface:

It all started with a misunderstanding.

I noticed a new page in the Gemini API documentation called Omni, introducing a model named Gemini Omni Flash, described as "natively multimodal, processing text, images, audio, and video simultaneously." My first thought was straightforward: if I throw a whole folder of videos and photos from my phone into it, let it understand what each asset is about, and then tell it in one sentence to edit them into a short video—isn't that a video editing app?

After reading the documentation, I realized I had misunderstood, and the misunderstanding happened to be at the most critical point. However, after bypassing that limitation, the rest was actually feasible. The result is ReelCraft: a Python CLI where you feed in a bunch of videos and photos, Gemini 3.7 Flash understands the assets one by one and provides editing suggestions. Once I confirm the edit list, ffmpeg cuts it into a 9:16 vertical short video, background music is generated using Lyria 3, and subtitles are automatically burned in.

Along the way, there were three issues where both ffmpeg and Gemini reported success, but the output was wrong—the kind of errors you only discover by actually playing the video.

TL;DR

This article will cover:

  • Omni Flash is not what I thought it was
  • Bypassing limitations: Per-file understanding, then text aggregation
  • Using edl.yaml as a human confirmation point
  • The difference after switching to Gemini 3.7 Flash
  • Background music: Lyria 3 uses a different API
  • ffmpeg will silently fail your edits
  • Subtitles: Two issues only visible after burning them in
  • Other pitfalls
  • Conclusion
  • Reference links

Omni Flash is not what I thought it was

Gemini Omni Flash (gemini-omni-flash-preview) is a video generation and editing model that uses the Interactions API. It allows you to use natural language to apply effects to a single video, such as "when the person touches the mirror, make the mirror ripple beautifully like liquid." It is not a tool for "understanding a bunch of videos."

The limitation section states clearly:

Referencing or reasoning across multiple videos is not supported. Attempting multi-video prompting may result in degraded model performance or unexpected outputs.

Additionally:

Video references up to 3 seconds in duration are accepted by the API schema but are not correctly processed by the model at this time.

So the path of "throwing a bunch of videos in and letting it understand and edit them" was blocked for Omni Flash. The models that can actually perform multi-video understanding are the standard Gemini models: starting from version 2.5, a single request can include up to 10 videos. With a 1M context window, it can handle about an hour of footage at default resolution, tokenize it second-by-second, and output scene descriptions with timestamps.

The time spent on this misunderstanding wasn't wasted. The verification process helped clarify "which task should be handled by which model," and the architecture followed naturally.

Bypassing limitations: Per-file understanding, then text aggregation

The entire pipeline is split into five stages, with states stored in files:

[Asset Folder]
     │ poc ingest: Scan videos/photos → catalog.json
     ▼
     │ poc analyze: Call Gemini for each file individually → analysis/*.json
     ▼
     │ poc plan: Aggregate all analysis results, call once for editing suggestions
     ▼ → summary.md (for humans) + edl.yaml (for machine execution)
     ⏸ Human inspection and editing of edl.yaml
     ▼
     │ poc render: ffmpeg editing, 9:16 cropping, xfade transitions
     ▼
output/final.mp4

The key design decision is in the second and third steps: call Gemini once for each video to get precise internal timestamps and descriptions; then feed these text results (not the raw videos) into a second call for cross-asset aggregation, sequencing, and editing suggestions.

This approach has two benefits. First, it completely avoids the "multi-video reasoning not supported" issue because the second call only sees text, not ten videos. Second, it isn't limited by the 10 videos/request cap; no matter how many assets there are, it just means more independent calls in the analyze phase. Those calls can be retried or fail individually without affecting each other.

Testing also proved that timestamps are more reliable when processed separately. When asking about ten videos in a single prompt ("which seconds are the highlights?"), the model easily confuses the timelines of different videos.

Failure handling in the analyze phase is recorded separately: if a file fails after three retries, it's logged in analysis/_errors.json, while other files continue. This later revealed a loophole during review, which I'll discuss later.

Using edl.yaml as a human confirmation point

I decided from the start not to make it "one-click fully automatic." Between inputting assets and outputting the final product, there must be a place where I can manually intervene, because LLM-provided edit points will inevitably have some irrationalities, and re-running the entire pipeline incurs API costs again.

That interface is a YAML file:

target_duration_sec: 23
aspect_ratio: '9:16'
clips:
- source: /abs/path/808327978.mp4
  note: Opening shot: Showing the COSCUP x UbuCon Asia main visual backdrop.
  in: '00:00.000'
  out: '00:02.500'
- source: /abs/path/S__1908753.jpg
  note: Fun venue easter egg: Creative semiconductor chip snacks distributed on-site.
  duration_sec: 4.0
transitions: crossfade 0.3s
mood_tags: [Professional, Joyful, Community Cohesion]

Videos use in/out to mark the range, photos use duration_sec for duration, and note is the reason for selection written by Gemini (this field was later used for subtitles, see below). To change an edit point, just change the numbers; to change the order, move the clip; after saving, run poc render.

The outputs of each stage remain in the project directory, so any step can be re-run individually. analyze also skips files that already have analysis results, so re-running doesn't incur double charges—this is very helpful when iterating on prompts.

poc plan --theme was added later: you can provide a sentence as the editing theme, e.g., --theme "Participating in the COSCUP open source community". This affects the narrative angle of the summary, the priority of clip selection, and the wording of each clip's note. Since it only affects the plan stage, changing the theme doesn't require re-analyzing assets, making it very cheap to try different narratives on the same set of materials.

The difference after switching to Gemini 3.7 Flash

The understanding and aggregation stages initially used gemini-2.5-flash, then switched to gemini-3.7-flash. This is the GA stable version, not a preview:

Item Specification
Model ID gemini-3.7-flash
Input 1,048,576 tokens
Output 65,536 tokens
Input Types Text, Image, Video, Audio, PDF
Capabilities structured outputs, function calling, caching, thinking (low/medium/high)
Not Supported Video/Image/Audio generation, Live API

For this project, the most important features are structured outputs and video input, as the analyze stage involves feeding in a video and requesting a JSON with a fixed schema.

After switching, I didn't just change the string and call it a day; I verified it with actual API calls, running analyze_file on real assets. For the same lecture video, the difference in descriptions between the two models was quite noticeable.

gemini-2.5-flash version:

At the start of the video, a woman on stage uses a microphone to introduce herself to the audience. The large screen behind her shows her name "Zona Wang" and her job description.

gemini-3.7-flash version:

In the video, a female speaker (Zona Wang, LINE Technology Evangelist) is giving a self-introduction and presentation on a stage in a lecture hall, followed by a camera pan across the audience listening intently.

The difference lies in "job description" vs. "LINE Technology Evangelist." The latter actually read the small text on the slide, while the former only knew there was some job information there.

The gap in the aggregation stage was even larger. For the same set of COSCUP assets and the same --theme, 2.5's summary was: "This short video aims to showcase the vitality and diversity of the COSCUP open source community. From professional knowledge sharing and deep technical exchange to warm interaction and inclusion among community members"—the whole thing stayed at an abstract level. 3.7 recognized the full event name "COSCUP x UbuCon Asia," booth names like "FOSS for All" and "Kubernetes," and even described a photo as "Creative semiconductor chip snacks distributed on-site." These details weren't in my prompt; they all came from the text and objects in the photos.

For an application where "asset understanding quality directly determines editing quality," the benefit of switching models was greater than I expected. The editing suggestions improved because it actually understood more, not because the prompt was written better.

By the way, 3.7's note style also changed to a "Short Label: Detailed Description" format. This change later broke all my subtitles, as discussed below.

Background music: Lyria 3 uses a different API

Background music is generated using Lyria 3. There are two models: lyria-3-clip-preview for 30-second clips, and lyria-3-pro-preview for full songs. My output is about 20 seconds, so the clip version is perfect.

It doesn't require a separate Vertex AI application or allowlisting; the same Gemini API key works. However, the calling method is completely different from generate_content, using client.interactions.create():

interaction = client.interactions.create(
    model="lyria-3-clip-preview",
    input="An instrumental background music track for a short social-media video, "
          "about 20 seconds long. Mood: Professional, Joyful, Community Cohesion, Happy. "
          "No vocals, no lyrics, loopable.",
)
audio_bytes = base64.b64decode(interaction.output_audio.data)

Several things were different from what I imagined.

It has no structured parameters. Length, BPM, genre, and mood must all be written in the natural language prompt, rather than passing a field like bpm=120. So the generate_score(mood_tags, duration_sec) function's job is actually to concatenate mood tags and seconds into an English sentence. Mood tags are aggregated from asset analysis results during the plan stage, and poc render --mood "Happy, Joyful, Celebration" can further overlay desired directions.

It is single-turn generation and cannot be iteratively modified. Unlike Omni Flash's video editing, once the music is generated, it's set; if you're not satisfied, you have to submit a new prompt. All generated audio includes a SynthID watermark.

When the music is shorter than the video, you have to handle it yourself. The clip version is max 30 seconds, but the video might be longer. So during mixing, I use -stream_loop -1 to loop the audio infinitely and -shortest to trim it to the video length:

cmd.extend(["-stream_loop", "-1", "-i", str(audio_path)])
# ... filter_complex, map video ...
cmd.extend(["-map", f"{audio_index}:a", "-c:a", "aac", "-b:a", "128k", "-shortest"])

Music generation failure (quota, network, safety filters) won't crash the entire render; it prints a warning and falls back to silent output. This principle was later added to the project's CLAUDE.md: any value-added feature calling an external generative API must degrade gracefully and not let the main process die because of a secondary feature.

ffmpeg will silently fail your edits

The render stage uses ffmpeg's xfade filter to connect clips. Each xfade requires an offset parameter, which is "at which second in the output timeline to start this transition." The logic for accumulation is: the sum of all previous clip lengths minus the seconds overlapped by each transition.

After writing the first version, unit tests were all green, and real assets produced normal videos. Then review identified two scenarios where ffmpeg returns exit code 0, but the output file is wrong.

Scenario one: The transition is longer than the clip, causing the clip to be silently swallowed. For two 1-second clips with transitions: "crossfade 2s", the calculated offset is -1.000. ffmpeg accepts this negative number, doesn't report an error, and finishes normally. The output is a 1-second video containing only the first clip; the second one disappears entirely. Since EDL.transitions is a free-text field, it's entirely possible for me to type 3s instead of 0.3s when manually editing the YAML, and it won't tell me in any way.

Scenario two: out exceeds the actual asset length, causing everything following it to be truncated. For a 10-second video, if the EDL says in: 8.0 / out: 15.0, only 2 seconds can actually be taken. If a 1.5-second photo follows, the offset is calculated as 6.700, which falls after the end of the first stream. The result is a 2-second output where the photo is completely missing, and the exit code is still 0. This scenario is even more important to prevent because the EDL is generated by an LLM, and hallucinating an out-of-bounds end time is quite natural.

I added explicit checks for both: if a negative offset is calculated, a ValueError is thrown specifying which clip and transition length; before rendering, ffprobe is used to read the actual length of each video asset, and if out exceeds it, an error is reported clearly stating the requested vs. actual duration.

I care so much because a "successful" but incorrect output is much worse than a crash. If it crashes, I know to fix it immediately. With exit code 0 and a seemingly normal mp4, I might not notice until I watch the whole video and think "wait, a segment is missing," and then have no idea where to start investigating.

Subtitles: Two issues only visible after burning them in

image-20260814153330478

The source for subtitles is the note for each clip in the EDL—the editing reason written by Gemini. Since it already wrote a description for each segment, using it as an on-screen title is perfect.

The implementation doesn't use drawtext; instead, it generates an SRT file and burns it in using libass's subtitles filter. The reason is that drawtext requires manual handling of Chinese font paths and escaping characters; colons, commas, and single quotes all clash with filtergraph syntax. SRT with force_style is much cleaner, and specifying FontName=Noto Sans TC lets fontconfig find the Chinese font.

The first issue was two subtitles appearing on screen simultaneously. In the first version, each subtitle's display interval was just the clip's own start and end times. But with a 0.3s crossfade overlap between adjacent clips, those 0.3 seconds would have two lines of white text on a black background stacked together, which looked ugly. The fix was to change each subtitle's end time to "when the next clip starts" rather than its own end time, ensuring at most one subtitle is visible at any moment. Unit tests couldn't catch this because the SRT was perfectly valid and ffmpeg burned it successfully; I only found it by looking at the frames.

The second issue was subtitles all trailing with an ellipsis. The note is a full sentence description, which would fill the screen if burned directly, so it's truncated into a short title: cut at the first comma or period, or use a character limit if no punctuation is found, adding "..." if truncated.

After switching to Gemini 3.7 Flash, this rule fell apart. 3.7 tends to write notes in a "Opening shot: Showing the 2024 COSCUP x UbuCon Asia main visual backdrop" format—a "Short Label: Detailed Description" style. Since colons weren't in my sentence-breaking character list, the whole sentence fell into the character-limit truncation path, and all eight subtitles ended with "...".

Hard truncation had a second flaw: it ignored word boundaries. "Presenting the female speaker sharing presentation content about ChatGPT and Antigravity" cut at the 20th character resulted in "...and An...", a halved English word.

I fixed both: colons are now treated as label separators, and the label itself is used as the full title without an ellipsis; when hard truncation is necessary, if the cut point falls in the middle of a continuous string of English letters/numbers, it backtracks to before that string started, discarding the whole segment rather than cutting it in half. I also relaxed the character limit from 20 to 24.

After re-burning, the eight subtitles became clean short titles like "Opening Shot," "Session Hall Live," "Technical Sharing Close-up," "Venue Easter Egg," and "Community Booth Interaction," without a single ellipsis.

Other pitfalls

files.upload() returning doesn't mean the file is ready. This was caught by digging into the SDK source code during review, and it would crash on the real API while never showing up in tests. client.files.upload() returns as soon as the bytes are transferred, without waiting for server-side processing. After a video is uploaded, it stays in a PROCESSING state for several seconds; trying to use it for generate_content during this time results in a 400 FAILED_PRECONDITION.

Worse, my original retry loop made things worse: analyze_file was wrapped in a retry, so each retry re-uploaded the entire video and immediately failed again, with only about 3 seconds of backoff across three tries. After three tries, the asset went into _errors.json, and the plan stage didn't read that file at the time, so the asset silently disappeared from the final product. The fix was adding a wait_for_active(), polling client.files.get() after upload until the state is ACTIVE before proceeding, and moving the upload out of the retry loop.

_errors.json was written but not read. As mentioned, analyze diligently recorded failed assets, but plan didn't read them, and summary.md wouldn't mention them. The only way a user could notice was by counting the segments in the final product. Now plan attaches the failure list to the end of the summary, explicitly stating which assets were not included.

Re-running ingest can bite you with old analysis results. This was encountered during actual use, not review. I changed the contents of the asset folder, adding new photos and deleting old ones, then re-ran poc ingest. catalog.json was updated, but analysis results for deleted files were still sitting in analysis/. When plan read the analysis results, it didn't cross-reference them with the current catalog, so it fed outdated assets to Gemini. The model reasonably picked a segment from them, but since the file no longer existed, the whole plan failed. Now load_analyses() filters by the catalog and prints which outdated records are ignored.

Timestamp precision. format_timestamp initially used :04.1f, keeping only one decimal place. Every time an EDL went in and out of YAML, it lost up to 0.05 seconds, which is about 1.5 frames at 30fps, causing edit points to drift. I changed it to :06.3f to keep millisecond precision.

Looking back, these problems fall into two categories. files.upload and ffmpeg silent errors were caught by reading the code line-by-line during review. Subtitle overlapping, ellipsis issues, and stale analysis results only surfaced by actually running the code, playing the videos, and trying different sets of assets. When the tests were all green, those three issues were still lurking in the code.

Conclusion

What ReelCraft does now is simple: a folder of videos and photos goes in, and a 9:16 short video with music and subtitles comes out, with a YAML file in the middle that I can manually edit.

Architecturally, what really makes this work is the "per-file understanding, text aggregation" split. It was conceived to bypass Omni Flash's lack of multi-video reasoning, but it ended up solving timestamp precision and asset count limits as well. After switching to Gemini 3.7 Flash, the granularity of asset understanding significantly increased, and the editing suggestions improved accordingly—the gains here were greater than what I got from tuning prompts.

Two areas remain untouched: Omni Flash's single-clip generative touch-up has an empty touch_up_clip interface, and subtitles are currently derived automatically from note, with the text_overlays field still empty. Neither music nor subtitles are cached; they are re-generated every time render is run.

Reference Links:

Read the whole story
alvinashcraft
2 hours ago
reply
Pennsylvania, USA
Share this story
Delete

Loops and beads: orchestrating AI agents with Postman

1 Share

Every AI agent boils down to the same three moves: decide what to do, do it, look at the result. The part that matters is how you wire those moves together. Get the wiring wrong and a two-second task takes six. Get it wrong badly enough and a single stuck step takes the whole agent down with it.

I’ve been thinking about this in terms of two patterns I wrote about separately: loops and beads. A loop is temporal repetition: call the model, run a tool, feed the result back, repeat until done. A bead is structural composition: a small unit of work with a defined input and output, wired into a graph alongside other beads. Loops are great for open-ended, conversational tasks. Beads are better once you know the shape of the work in advance and some of it can happen in parallel.

The Postman plugin for Claude Code turned out to be a good place to see the difference firsthand. It ships eight commands, backed by the Postman MCP server, for syncing collections, generating client code, running tests, creating mocks, publishing docs, auditing security, and scoring API readiness. In this post I’ll build the same small agent twice against the Postman API, once as a loop and once as a bead graph, and show you the code so you can run both yourself.

What loops and beads mean in code

A loop agent is one conversation with the model that keeps going until the model says it’s done. You define a set of tools, hand the model a question, and every time it asks for a tool call, you run that tool and feed the result back in. Claude’s Messages API is stateless, so each turn resends the full conversation history, and the model decides one step at a time what happens next. That’s the ReAct pattern most agent frameworks default to, and it’s a fine default: it’s simple, and the model can change its mind mid-task.

A bead agent is a graph you define up front. Each bead is a function with a name, a job, and a list of dependencies. A bead only runs once its dependencies have finished, and beads with no dependency on each other run at the same time. Nothing here is unique to Postman or Claude. It’s closer to how you’d design a build pipeline or a data processing DAG than to a chat loop, and that’s exactly the point: once a task’s structure is known, you don’t need the model to rediscover it turn by turn.

The plugin’s own command set is already bead-shaped. /postman:test and /postman:security don’t depend on each other’s output. Neither does /postman:docs. If an agent needs all three, running them one after another in a loop wastes time waiting on network calls that have nothing to do with each other. That’s the exact case beads are built for.

Building the same agent twice

To make the comparison concrete, I wrote one task two ways: “Is my API collection healthy?” The task needs two pieces of information, a summary of a Postman Collection and the pass/fail result of a Postman Monitor run. Neither depends on the other.

The full, runnable script is in postman_loops_and_beads.py in the companion repo. I’ll walk through the parts that matter here, but grab the file if you want to run it against your own workspace.

The loop version

The loop agent gives Claude two tools and lets it decide when to call them:

TOOLS = [
    {
        "name": "get_collection_summary",
        "description": "Fetch a Postman Collection by ID and summarize its request count and auth type.",
        "input_schema": {
            "type": "object",
            "properties": {"collection_id": {"type": "string"}},
            "required": ["collection_id"],
        },
    },
    {
        "name": "run_monitor",
        "description": "Trigger a Postman Monitor run by ID and return its pass/fail summary.",
        "input_schema": {
            "type": "object",
            "properties": {"monitor_id": {"type": "string"}},
            "required": ["monitor_id"],
        },
    },
]

The loop itself is short. Call the model, check if it asked for a tool, run the tool, append the result, repeat:

while True:
    response = await anthropic.messages.create(
        model=MODEL, max_tokens=1024, tools=TOOLS, messages=messages
    )
    messages.append({"role": "assistant", "content": response.content})

    if response.stop_reason != "tool_use":
        break

    tool_results = []
    for block in response.content:
        if block.type == "tool_use":
            result = await call_tool(block.name, block.input)
            tool_results.append(
                {"type": "tool_result", "tool_use_id": block.id, "content": json.dumps(result)}
            )
    messages.append({"role": "user", "content": tool_results})

This works and it’s easy to follow. The catch is timing. Claude can only ask for one thing at a time in this setup, so it fetches the collection, waits for the result, then decides to run the monitor, and waits again. The two lookups never overlap even though nothing requires them not to.

The bead version

The bead version replaces the loop with an explicit graph. A bead is a small dataclass:

@dataclass
class Bead:
    name: str
    run: Callable[[dict, dict], Awaitable[Any]]
    deps: tuple[str, ...] = ()

And a graph runner that runs every bead whose dependencies are already done, one layer at a time, using asyncio.gather to run each layer concurrently:

async def run_graph(beads: list[Bead], context: dict) -> dict[str, Any]:
    done: dict[str, Any] = {}
    remaining = {b.name: b for b in beads}

    while remaining:
        ready = [b for b in remaining.values() if all(d in done for d in b.deps)]
        if not ready:
            raise RuntimeError(f"Unmet bead dependencies: {list(remaining)}")

        results = await asyncio.gather(*(b.run(context, done) for b in ready))
        for bead, result in zip(ready, results):
            done[bead.name] = result
            del remaining[bead.name]

    return done

The graph for this agent has four beads. classify_intent runs first, using a fast Haiku call to decide whether the question needs the collection lookup, the monitor run, or both, so a bead can skip its own work based on what an earlier bead decided. That’s the first-class branching the beads post talks about: the decision lives in the graph, not buried in a prompt. fetch_collection and run_monitor both depend only on classify_intent, so they run at the same time. build_report depends on both and merges their output into an answer:

beads = [
    Bead("classify_intent", bead_classify_intent),
    Bead("fetch_collection", bead_fetch_collection, deps=("classify_intent",)),
    Bead("run_monitor", bead_run_monitor, deps=("classify_intent",)),
    Bead("build_report", bead_build_report, deps=("fetch_collection", "run_monitor")),
]
done = await run_graph(beads, context)

Four beads, three layers, and the middle layer runs both of its beads in parallel instead of waiting on itself.

Running the comparison

The script has a --demo flag that swaps the real Postman API calls for canned responses with realistic delay (1.2 seconds for the collection lookup, 1.8 seconds for the monitor run), so you can see the timing difference without a Postman API key:

pip install anthropic httpx python-dotenv
echo "ANTHROPIC_API_KEY=sk-ant-..." > .env

python3 postman_loops_and_beads.py --demo --pattern compare

On my machine that prints something close to this:

=== loop pattern ===
Your collection has 14 requests using bearer auth, and the monitor run passed all 22 assertions.

[loop] wall clock: 3.02s

=== beads pattern ===
Your collection (14 requests, bearer auth) looks fine, and the monitor run passed all 22 assertions with no failures.

[beads] wall clock: 1.83s

beads finished faster (loop: 3.02s, beads: 1.83s) because independent beads ran in parallel.

The loop’s time is close to the sum of both simulated delays, 1.2 seconds plus 1.8 seconds. The bead graph’s time is close to the slower of the two, because fetch_collection and run_monitor ran together. That’s the same sum-or-slowest-operation tradeoff from the beads post‘s travel-brief example, measured here instead of described.

Drop --demo and add real IDs to hit the live Postman API with your own credentials. Add your Postman API key to the same .env file:

echo "POSTMAN_API_KEY=PMAK-..." >> .env

python3 postman_loops_and_beads.py --pattern beads \
  --collection <your_collection_id> --monitor <your_monitor_id>

Where each pattern fits with the plugin

I wouldn’t rewrite every agent as a bead graph. The two patterns solve different problems, and the plugin’s own commands split cleanly along that line.

A loop still makes sense for anything genuinely conversational, where you don’t know the next step until you see the result of the last one. Debugging a failing test with /postman:test, following up on findings from a /postman:security audit, or iterating on client code with /postman:sync all fit that shape. The model needs room to change direction.

A bead graph pays off once you know the shape of the work ahead of time and some of it doesn’t depend on the rest. /postman:test and /postman:security don’t read each other’s output. Neither does checking a spec against the agent readiness analyzer that scores APIs across eight pillars. An agent that runs “test, audit, and score” as three independent beads finishes in roughly the time of the slowest one, not all three added together. And if the security audit bead fails, you retry that bead alone instead of rerunning tests that already passed. That’s the granular re-entry the beads post calls out, and it matters more as the number of steps grows.

Try it yourself

Clone the script and run it against a real Postman workspace:

git clone https://github.com/quintonwall/loops-and-beads.git
cd loops-and-beads
pip install anthropic httpx python-dotenv
cp .env.example .env   # then fill in your own keys

Then try changing the question. Ask it to only check the monitor, and watch classify_intent skip the collection lookup entirely. Add a third bead, maybe a call to the agent readiness checks, depending only on classify_intent, and it joins the parallel layer for free. That’s the part of beads that’s easy to miss from the outside: adding independent work doesn’t add latency. It adds another item to the same asyncio.gather call.

If you haven’t set up the Postman plugin for Claude Code yet, install it and run /postman:setup to authenticate. Once it’s running, try asking it to test and audit an API in the same message and watch which commands it fires off. You’ll start noticing which parts of your own agent workflows are secretly loops and which ones have been beads all along.

Resources

The post Loops and beads: orchestrating AI agents with Postman appeared first on Postman Blog.

Read the whole story
alvinashcraft
2 hours ago
reply
Pennsylvania, USA
Share this story
Delete
Next Page of Stories