<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[owen090's blog]]></title><description><![CDATA[owen090's blog]]></description><link>https://owen090.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Tue, 27 Oct 2026 19:53:50 GMT</lastBuildDate><atom:link href="https://owen090.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Inside an Image-to-Video Motion Transfer Pipeline: From One Photo to a 9:16 Dance Clip]]></title><description><![CDATA[A developer's walkthrough of the stages behind "make this photo dance": pose extraction, conditioning, temporal consistency, and encoding for vertical…]]></description><link>https://owen090.hashnode.dev/inside-an-image-to-video-motion-transfer-pipeline-from-one-photo-to-a-9-16-dance-clip</link><guid isPermaLink="true">https://owen090.hashnode.dev/inside-an-image-to-video-motion-transfer-pipeline-from-one-photo-to-a-9-16-dance-clip</guid><category><![CDATA[Machine Learning]]></category><category><![CDATA[Computer Vision]]></category><category><![CDATA[generative ai]]></category><category><![CDATA[Python]]></category><category><![CDATA[FFmpeg]]></category><dc:creator><![CDATA[yu meng]]></dc:creator><pubDate>Thu, 22 Oct 2026 05:59:06 GMT</pubDate><content:encoded><![CDATA[<p>"Upload a photo, get a dancing video" is one of the easiest AI features to describe and one of the trickiest to make look good. Under the hood it's a pipeline of several separate problems, and each one can quietly ruin the output.</p>
<p>This article walks through that pipeline at an architectural level. It's based on the kind of system behind tools like <a href="https://danceaigenerator.com/">AI Dance Generator</a>, which turns one image into a 9:16 MP4. I'll stay model-agnostic, because the specific checkpoints change every few months while the structure stays the same.</p>
<h2>The problem, stated precisely</h2>
<p>Given:</p>
<ul>
<li>a <strong>source image</strong> <code>I</code> containing one subject, and</li>
<li>a <strong>driving motion</strong> <code>M</code>, which is a sequence of poses <code>p_1 … p_T</code>,</li>
</ul>
<p>produce a video <code>V = (f_1 … f_T)</code> where each frame shows <em>the subject from I</em> in pose <code>p_t</code>, with:</p>
<ul>
<li><strong>identity preserved</strong> (face, body shape, clothing),</li>
<li><strong>temporal coherence</strong> (no flicker or texture crawl between frames), and</li>
<li><strong>plausible occlusion</strong> (parts of the body hidden in <code>I</code> must be invented convincingly when they come into view).</li>
</ul>
<p>That last point is the hard part. A front-facing photo contains no information about the subject's back, so a turn requires the model to hallucinate it consistently.</p>
<h2>Stage 1: Input validation and preprocessing</h2>
<p>Don't spend GPU time on images that can't work. A cheap CPU or small-GPU pre-pass should:</p>
<ol>
<li><strong>Detect people</strong> and reject images with zero or several subjects (or ask the user to choose one).</li>
<li><strong>Check coverage.</strong> Estimate keypoints and confirm the head and torso are visible. Missing limbs are recoverable; a missing head usually isn't.</li>
<li><strong>Normalize.</strong> Fix EXIF orientation, convert to RGB, and resize to the model's working resolution while keeping the aspect ratio.</li>
<li><strong>Segment the subject.</strong> A foreground mask lets you treat the subject and background differently later.</li>
</ol>
<pre><code class="language-python">def preprocess(img):
    img = apply_exif_orientation(img).convert("RGB")
    people = detect_people(img)
    if len(people) != 1:
        raise InputError("Please upload a photo with exactly one person.")
    kp = estimate_keypoints(img, people[0])
    if not kp.has(["nose", "left_shoulder", "right_shoulder"]):
        raise InputError("Head and shoulders must be visible.")
    mask = segment_subject(img, people[0])
    return resize_keep_aspect(img, target=768), kp, mask
</code></pre>
<p>Returning a clear, specific error here is better UX than returning a broken video later.</p>
<h2>Stage 2: Representing the driving motion</h2>
<p>The dance is stored as pose data, not as video. Typical representations:</p>
<ul>
<li><strong>2D skeleton keypoints</strong> (OpenPose / COCO style): cheap and widely supported, but ambiguous in depth.</li>
<li><strong>Dense pose / UV maps</strong>: map each pixel to a body-surface coordinate, giving richer conditioning.</li>
<li><strong>3D body model parameters</strong> (SMPL-like): handle rotation and depth best, but cost more to extract.</li>
</ul>
<p>Motion is extracted once per dance from a reference clip, then cleaned up offline: smooth jitter with a one-euro or Savitzky–Golay filter, fill dropped keypoints, and trim to a clean loop.</p>
<h3>Retargeting</h3>
<p>The reference dancer and your subject have different proportions. If you feed raw keypoints, a short subject gets stretched limbs. Retarget by:</p>
<ol>
<li>Computing bone lengths for the source subject from its keypoints.</li>
<li>Rescaling each bone in the driving sequence to match, while keeping joint angles.</li>
<li>Anchoring the root (mid-hip) to the subject's position and scale in the frame.</li>
</ol>
<p>Skipping this step is one of the most common causes of "something looks off" results.</p>
<h2>Stage 3: The generative core</h2>
<p>Most current systems use a <strong>video diffusion model</strong> with two conditioning paths:</p>
<ul>
<li><strong>Appearance conditioning:</strong> features from the source image, injected via a reference network, cross-attention, or both. This carries identity, clothing, and texture.</li>
<li><strong>Pose conditioning:</strong> the retargeted pose sequence, encoded by a lightweight pose guider and added to the noisy latents, similar in spirit to ControlNet.</li>
</ul>
<p>Temporal layers (attention across frames) give the model a chance to keep frames consistent with one another. Frames are generated in latent space and decoded by a VAE.</p>
<p>Practical considerations:</p>
<ul>
<li><strong>Clip length vs. memory.</strong> Attention across frames grows quickly with <code>T</code>. Long videos are usually generated in overlapping windows and blended at the seams.</li>
<li><strong>Sampling steps.</strong> Fewer steps mean faster jobs and more artifacts. Choose per product tier, not globally.</li>
<li><strong>Seed control.</strong> Fix the seed per job so retries are reproducible when you debug.</li>
</ul>
<h2>Stage 4: Temporal consistency and cleanup</h2>
<p>Even good models produce flicker in high-frequency areas such as hair, patterned clothing, and hands. Mitigations, from cheapest to most expensive:</p>
<ul>
<li><strong>Overlap-and-blend windows</strong> with a crossfade over the shared frames.</li>
<li><strong>Background locking:</strong> composite the generated subject over a stabilized version of the original background using the mask from Stage 1, so the background doesn't "breathe."</li>
<li><strong>Face restoration</strong> on a per-frame crop, blended back with a feathered mask. Use it lightly, because overdoing it creates a pasted-on look.</li>
<li><strong>Frame interpolation</strong> to raise the frame rate after generating at a lower one.</li>
</ul>
<h2>Stage 5: Framing for 9:16</h2>
<p>The output targets vertical platforms, so composition matters:</p>
<ul>
<li>Track the subject's bounding box across all frames, then compute <strong>one smoothed crop path</strong>. Per-frame cropping causes a jittery virtual camera.</li>
<li>Leave headroom. Platform UI covers the top and bottom of the screen.</li>
<li>Pad rather than crop when the dance moves wide, so arms aren't cut off.</li>
</ul>
<h2>Stage 6: Encoding and delivery</h2>
<p>Encode for maximum compatibility:</p>
<pre><code class="language-bash">ffmpeg -framerate 24 -i frames/%05d.png \
  -vf "scale=1080:1920:flags=lanczos,format=yuv420p" \
  -c:v libx264 -profile:v high -crf 18 -preset slow \
  -movflags +faststart output.mp4
</code></pre>
<ul>
<li><code>yuv420p</code> and H.264 High play everywhere.</li>
<li><code>+faststart</code> moves the metadata to the front so the video starts playing before it fully downloads.</li>
<li>Add a silent audio track if a platform rejects video-only files, or mux in the dance's music where licensing allows.</li>
</ul>
<h2>System architecture</h2>
<p>Generation takes seconds to minutes, so it must be asynchronous:</p>
<ol>
<li>API receives the upload and runs Stage 1 synchronously, so bad input fails fast.</li>
<li>A job goes into a queue (Redis, SQS, or similar) with an idempotency key, so a double-click doesn't bill or render twice.</li>
<li>GPU workers pull jobs, run Stages 2–6, and upload the MP4 to object storage.</li>
<li>The client polls or subscribes to a status endpoint, then gets a signed URL.</li>
</ol>
<p>This structure also fits <strong>per-output billing</strong>, which is what a pay-per-video product does: you charge when a job is created and finished, which lines up neatly with GPU cost per job.</p>
<h2>Testing a generative pipeline</h2>
<p>Unit tests won't tell you whether a dance looks good, but you can still automate a lot:</p>
<ul>
<li><strong>Golden inputs:</strong> a fixed set of tricky photos (side profile, loose clothing, pet, low light) rendered on every model or config change.</li>
<li><strong>Cheap metrics:</strong> face-embedding similarity between source and frames (identity drift), and frame-to-frame difference in background regions (flicker).</li>
<li><strong>Human review</strong> of a contact sheet before shipping a new checkpoint.</li>
</ul>
<h2>Takeaways</h2>
<ul>
<li>Most visible quality problems come from <strong>preprocessing, retargeting, and framing</strong>, not the diffusion model itself.</li>
<li>Treat motion as <strong>clean, retargeted pose data</strong> prepared offline.</li>
<li>Design for <strong>asynchronous, idempotent jobs</strong> from the start.</li>
<li>Encode <strong>boring, compatible MP4s</strong>: 1080×1920, H.264, yuv420p, faststart.</li>
</ul>
<p>If you want to see what this pipeline produces for a non-technical user, the <a href="https://danceaigenerator.com/make-a-photo-dance">make a photo dance</a> flow is a compact example of hiding all six stages behind a single upload button.</p>
]]></content:encoded></item><item><title><![CDATA[How I built six self-discovery quizzes around a Next.js registry]]></title><description><![CDATA[Quiz definitions, server-side scoring, versioned share links, on-demand OG images, and 612 tests]]></description><link>https://owen090.hashnode.dev/how-i-built-six-self-discovery-quizzes-around-a-next-js-registry</link><guid isPermaLink="true">https://owen090.hashnode.dev/how-i-built-six-self-discovery-quizzes-around-a-next-js-registry</guid><category><![CDATA[Next.js]]></category><category><![CDATA[React]]></category><category><![CDATA[TypeScript]]></category><category><![CDATA[Testing]]></category><category><![CDATA[webdev]]></category><dc:creator><![CDATA[yu meng]]></dc:creator><pubDate>Thu, 08 Oct 2026 08:29:38 GMT</pubDate><content:encoded><![CDATA[<p><em>This post was originally published on <a href="https://dev.to/owen090/how-i-built-six-self-discovery-quizzes-around-a-nextjs-registry-published-5202">dev.to</a>.</em></p>
<p><a href="https://loretypes.com/">Loretypes</a> is a small site with six self-discovery quizzes: Aura Color, Archetype, Moral Alignment, Past Life, Color Season, and Spirit Animal. They're meant for self-reflection and fun, not diagnosis. Past Life is openly fictional storytelling, and Color Season asks which color combinations you enjoy rather than looking at your face or skin.</p>
<p>From an engineering point of view, the interesting part is that the six quizzes share most of their plumbing (routing, submission, storage, share links, images, SEO) while their scoring rules and result screens differ. The <a href="https://loretypes.com/archetype/">Archetype quiz</a> scores bipolar dimensions; the <a href="https://loretypes.com/aura/">Aura quiz</a> sums answers across seven energy dimensions and maps them to one of eight colors, with White as the result when they're balanced. This post walks through how the codebase holds those together, and where the shared abstraction deliberately stops.</p>
<h2>One registry, many routes</h2>
<p>Everything starts in <code>lib/tests/registry.ts</code>:</p>
<pre><code class="language-ts">export const testRegistry = [
  auraColorDefinition,
  archetypeDefinition,
  moralAlignmentDefinition,
  pastLifeDefinition,
  colorSeasonDefinition,
  spiritAnimalDefinition,
] as const satisfies readonly TestDefinition[];

validateRegistry([...testRegistry]);

export function getTestByCluster(cluster: string): TestDefinition | undefined {
  return testRegistry.find((definition) =&gt; definition.cluster === cluster);
}
</code></pre>
<p>Each definition lives in <code>lib/tests/definitions/</code> and implements the <code>TestDefinition</code> interface from <code>lib/tests/types.ts</code>: questions, answer scale, scoring configuration, result types, SEO copy, theme tokens, a visualization id, an OG template id, a version, and a key for saving drafts in the browser. A quiz has two identifiers: a <code>slug</code> used by the API and storage, and a <code>cluster</code> used in URLs. They can differ: Moral Alignment's slug is <code>moral-alignment</code>, while its URL is <code>/alignment/</code>.</p>
<p><code>validateRegistry</code> runs when the module loads, so a bad definition fails early. It rejects reserved route segments, duplicate slugs, and route segments that collide across quizzes.</p>
<p>The App Router pages are thin consumers of that registry. The quiz landing page at <code>app/[cluster]/page.tsx</code> enumerates its routes straight from it:</p>
<pre><code class="language-ts">export function generateStaticParams() {
  return testRegistry.map((definition) =&gt; ({ cluster: definition.cluster }));
}

export const dynamicParams = false;
</code></pre>
<p>The same definition supplies that page's metadata and its FAQ structured data. The saved-result page uses it to resolve result copy, and the image route uses its OG template id to pick a renderer.</p>
<p>That said, this isn't "drop in a config file and get a new quiz." Definitions contain functions (for example <code>mapVisualizationScores</code>), and the landing page explicitly branches into a specialized React experience per quiz. The registry connects content and infrastructure; it doesn't remove quiz-specific UI work.</p>
<h2>One runner, scoring on the server</h2>
<p>All six quiz experiences render the same <code>QuizRunner</code> component (<code>components/quiz/quiz-runner.tsx</code>). When you answer the last question, it posts only the answer array:</p>
<pre><code class="language-ts">const response = await fetch(`/api/tests/${definition.slug}/results/`, {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ answers: finalAnswers }),
  signal: controller.signal,
});
</code></pre>
<p>The handler in <code>app/api/tests/[slug]/results/route.ts</code> caps the body at 4 KB, applies a rate limit to result creation, looks up the definition by slug, and then calls <code>processSubmission</code>. The browser never tells the server which result it got. <code>validateSubmission</code> checks that <code>answers</code> is an array of exactly <code>questionCount</code> integers within the quiz's allowed range, and the server computes the result itself.</p>
<p>Scoring dispatch is a small strategy table in <code>lib/scoring/engine.ts</code>:</p>
<pre><code class="language-ts">export function scoreAnswers(definition: TestDefinition, answers: number[]): ResultPayload {
  const runner = strategyRegistry[definition.scoring.type];
  if (!runner) {
    throw new Error(`Scoring strategy is not implemented: ${definition.scoring.type}`);
  }
  return runner(definition, answers);
}
</code></pre>
<p>Four strategies are implemented: dimensional sum, bipolar dimensions, an alignment axis grid, and a seasonal profile. The strategy table is typed as a <code>Partial</code> record, which is honest: one strategy name in the type union (<code>weighted-type</code>) has no runner yet, and the engine throws instead of guessing.</p>
<p>The alignment strategy shows how simple the arithmetic can be. In <code>lib/scoring/strategies/axis-grid.ts</code>, each 1â€“5 answer is centered on 3 and multiplied by the question's direction:</p>
<pre><code class="language-ts">// Simplified: the question-kind check is omitted.
definition.questions.forEach((question, index) =&gt; {
  const contribution = (answers[index] - 3) * question.direction;
  if (question.axis === "lawChaos") lawChaos += contribution;
  else goodEvil += contribution;
});
const cell = cellFromAxes(lawChaos, goodEvil);
</code></pre>
<p>Dimensional scoring works differently. It sums answers per dimension, breaks ties by a configured order, and can return a "balanced" result when the spread between dimensions is small. The quizzes share an endpoint, not a formula.</p>
<h2>A share link points to a stored, versioned result</h2>
<p>After scoring, <code>saveResultWithRetry</code> in <code>lib/results.ts</code> stores a record with a 12-character lowercase alphanumeric share id (nanoid with a custom alphabet), plus the test slug, test version, scored payload, creation time, and an expiry that new records set to <code>null</code>. The raw answer array isn't part of that record.</p>
<p>On an id collision, the save loop tries a fresh id, up to five attempts, and rethrows any other storage error. The store, id factory, and clock are all injectable, which matters for testing (more below).</p>
<p>Storing the version is only useful if reads respect it. <code>lib/tests/result-version.ts</code> maps a stored record back to the definition it was scored with. Archetype keeps an explicit archived <code>0.2.0</code> definition, so old results keep rendering with the copy and types they were created with. A small read-only adapter also translates an older payload shape into the current field names, without rewriting the stored data:</p>
<pre><code class="language-ts">// Explicit archive, never a fallback to whichever definition is current.
const archetypeVersions: Record&lt;string, TestDefinition&gt; = { "0.2.0": v020 };
</code></pre>
<p>Not every quiz has a frozen archive yet. Alignment, Past Life, and Color Season have explicit version maps, Spirit Animal accepts only its current version, and Aura falls through to the current definition. The point is that each quiz has an explicit read policy, rather than a version field nobody checks.</p>
<p>The result page, <code>app/[cluster]/r/[shareId]/page.tsx</code>, validates the id format, loads the record, resolves the definition for its version, and picks the right result component. An unknown id or incompatible version ends in a 404.</p>
<h2>Static pages, dynamic results, and metadata</h2>
<p>Content routes are statically generated. The landing page, the detail index and type pages, and the learn articles all export <code>generateStaticParams</code> derived from the registry, with <code>dynamicParams = false</code>, so unknown slugs 404 instead of rendering on demand. The parameter lists aren't a blind cross product. A definition can turn detail pages off with <code>detailPages: false</code>, and Past Life currently has no type pages or learn articles.</p>
<p>Saved results are the opposite: there can be any number of them, so the result page looks records up by id rather than enumerating them. The result image and poster routes declare <code>runtime = "nodejs"</code> and <code>dynamic = "force-dynamic"</code>, and so does the sitemap.</p>
<p>Metadata is centralized in <code>lib/seo/metadata.ts</code>, which builds titles, descriptions, canonical URLs, Open Graph, and Twitter cards. Landing pages and articles point at pre-rendered static images. Result pages get a per-result image and are deliberately kept out of the index:</p>
<pre><code class="language-ts">return {
  title,
  description,
  alternates: { canonical },
  robots: { index: false, follow: true },
  openGraph: { title, description, url: canonical, images: [image] },
  twitter: { card: "summary_large_image", title, description, images: [image] },
};
</code></pre>
<p>The sitemap is built from <code>indexableClusterPaths</code>, which leaves out individual results and filters Archetype type pages through a content-quality gate. <code>robots.ts</code> allows crawling and points to the sitemap. Indexing is then controlled per page: crawlers can follow a result link, but results don't compete with the content pages in search.</p>
<h2>Share images are rendered on demand</h2>
<p>Each result has an OG image at <code>/[cluster]/og/[shareId].png</code>. The route delegates to <code>resultImageResponse</code> in <code>lib/result-image-route.ts</code>, which validates the id, loads the record, resolves its versioned definition, and picks a renderer from <code>lib/result-image/index.ts</code> using the definition's OG template id. The renderers use <code>ImageResponse</code> from <code>next/og</code>.</p>
<p>A successful render returns a PNG with <code>Cache-Control: public, max-age=31536000, immutable</code>, which is safe because a stored result is never updated (the store interface only has <code>create</code> and <code>find</code>). A render failure returns a 500 with <code>no-store</code>, so a broken image doesn't get cached for a year. Nothing is written to storage; the PNG is regenerated from the saved payload when a cache misses it.</p>
<p>Posters are a separate, opt-in capability. Past Life has no poster. Aura renders a 1200Ã—630 OG image, a 1080Ã—1080 square poster, and a 1080Ã—1920 story poster. Archetype and Alignment currently use one square layout for both poster routes.</p>
<p>Share buttons build their URLs with a tiny helper in <code>lib/share-url.ts</code>, which strips any query or hash and adds a channel marker:</p>
<pre><code class="language-ts">export function buildShareUrl(origin: string, pathname: string, channel: ShareChannel) {
  const cleanPathname = pathname.split(/[?#]/, 1)[0];
  const url = new URL(cleanPathname, origin);
  url.searchParams.set("s", channel);
  return url.toString();
}
</code></pre>
<h2>612 tests, mostly about contracts</h2>
<p>The suite runs on Vitest. At the time of writing it's 72 test files and 612 tests. <code>vitest.config.mts</code> defaults to the Node environment. The files that need a DOM opt in with a <code>// @vitest-environment jsdom</code> comment at the top.</p>
<p>A few patterns do most of the work:</p>
<ul>
<li><strong>Registry tests</strong> build deliberately invalid definitions (reserved segments, duplicate slugs, colliding routes, inconsistent SEO) and assert that <code>validateRegistry</code> throws.</li>
<li><strong>Persistence tests</strong> inject a fake store that throws duplicate-id errors, to check both "succeeds on a later attempt" and "gives up after five."</li>
<li><strong>Server components rendered to strings.</strong> About a dozen test files <code>await</code> an async server component directly, pass the tree to <code>renderToStaticMarkup</code>, and assert on headings, links, accessible table markup, and JSON-LD, without a browser.</li>
<li><strong>Golden HTML fixtures</strong> in <code>tests/__fixtures__/</code>. The homepage test renders <code>&lt;HomePage /&gt;</code> and compares the result with <code>toBe</code> against <code>home-portal.html</code>, byte for byte. The aura learn-article tests compare against their fixtures after stripping the shared footer, and separately assert one specific cross-link paragraph.</li>
</ul>
<h2>Two lessons</h2>
<p><strong>A version field needs a read policy.</strong> Writing <code>testVersion</code> into every record is easy. The hard part is deciding what happens when an old record is read after the copy or result types change. For Archetype, that meant keeping an explicit archive of the old definition and a read-only adapter for an earlier payload shape. Making the policy explicit per quiz (archive, exact match, or "current is fine") has been more useful than pretending every quiz is equally frozen.</p>
<p><strong>Byte-for-byte fixtures are noisy on purpose.</strong> The homepage fixture fails on any markup change, including a new footer link. That sounds annoying, but it turns "did this change anything else on the page?" into a diff you review. When the change is intended, you regenerate the fixture in the same commit and the diff shows exactly what moved. For pages where only part of the markup matters, like the learn articles, stripping the shared footer first keeps the fixture focused on the content it's meant to protect.</p>
<hr />
<p><em>I used AI assistance to help draft this post; the code and project are my own.</em></p>
]]></content:encoded></item></channel></rss>