feat(animation-sources): add content-driven guide system

This commit is contained in:
Poyraz
2026-08-29 17:33:25 +03:00
parent 4ffcaf18cd
commit a019910d61
15 changed files with 1850 additions and 49 deletions
@@ -0,0 +1,96 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { AnimationSourceDetailContent } from "@/components/animation-source-detail-content";
import { ArticleJsonLd } from "@/components/json-ld";
import {
getAnimationSourceBySlug,
listAnimationSources,
} from "@/data/animation-sources";
const SITE_URL =
process.env.NEXT_PUBLIC_SITE_URL || "https://poyrazavsever.com";
type AnimationSourceDetailPageProps = {
params: Promise<{ locale: string; slug: string }>;
};
function getLocalizedPath(locale: string, slug: string) {
const localePrefix = locale === "tr" ? "" : `/${locale}`;
return `${localePrefix}/animation-sources/${slug}`;
}
export async function generateStaticParams() {
const sources = await listAnimationSources();
return sources.map((source) => ({
locale: source.lang,
slug: source.slug,
}));
}
export const dynamicParams = false;
export async function generateMetadata({
params,
}: AnimationSourceDetailPageProps): Promise<Metadata> {
const { locale, slug } = await params;
const source = await getAnimationSourceBySlug(slug, locale);
if (!source) {
return { title: locale === "en" ? "Source not found" : "Kaynak bulunamadı" };
}
const url = `${SITE_URL}${getLocalizedPath(locale, source.slug)}`;
return {
title: source.title,
description: source.excerpt,
alternates: { canonical: url },
openGraph: {
title: source.title,
description: source.excerpt,
url,
type: "article",
publishedTime: source.date,
authors: [source.author],
images: [
{
url: source.coverImage,
width: 720,
height: 720,
alt: source.title,
},
],
},
twitter: {
card: "summary_large_image",
title: source.title,
description: source.excerpt,
images: [source.coverImage],
},
};
}
export default async function AnimationSourceDetailPage({
params,
}: AnimationSourceDetailPageProps) {
const { locale, slug } = await params;
const source = await getAnimationSourceBySlug(slug, locale);
if (!source) notFound();
const url = `${SITE_URL}${getLocalizedPath(locale, source.slug)}`;
return (
<>
<ArticleJsonLd
title={source.title}
description={source.excerpt}
url={url}
image={source.coverImage}
datePublished={source.date}
authorName={source.author}
/>
<AnimationSourceDetailContent source={source} />
</>
);
}
+45
View File
@@ -0,0 +1,45 @@
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { AnimationSourcesContent } from "@/components/animation-sources-content";
import { listAnimationSources } from "@/data/animation-sources";
type AnimationSourcesPageProps = {
params: Promise<{ locale: string }>;
};
export async function generateMetadata({
params,
}: AnimationSourcesPageProps): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "AnimationSources" });
return {
title: t("title"),
description: t("description"),
alternates: {
canonical: "/animation-sources",
},
};
}
export default async function AnimationSourcesPage({
params,
}: AnimationSourcesPageProps) {
const { locale } = await params;
const [sources, t] = await Promise.all([
listAnimationSources(locale),
getTranslations({ locale, namespace: "AnimationSources" }),
]);
return (
<AnimationSourcesContent
sources={sources}
labels={{
title: t("title"),
description: t("description"),
empty: t("empty"),
itemCount: t("itemCount", { count: sources.length }),
}}
/>
);
}
+21 -2
View File
@@ -1,11 +1,15 @@
import { listBlogDetails } from "@/data/blog-detail"; import { listBlogDetails } from "@/data/blog-detail";
import { listAnimationSources } from "@/data/animation-sources";
import type { MetadataRoute } from "next"; import type { MetadataRoute } from "next";
const SITE_URL = const SITE_URL =
process.env.NEXT_PUBLIC_SITE_URL || "https://poyrazavsever.com"; process.env.NEXT_PUBLIC_SITE_URL || "https://poyrazavsever.com";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> { export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await listBlogDetails(); const [posts, animationSources] = await Promise.all([
listBlogDetails(),
listAnimationSources(),
]);
const staticRoutes: MetadataRoute.Sitemap = [ const staticRoutes: MetadataRoute.Sitemap = [
{ {
@@ -62,6 +66,12 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
changeFrequency: "monthly", changeFrequency: "monthly",
priority: 0.4, priority: 0.4,
}, },
{
url: `${SITE_URL}/animation-sources`,
lastModified: new Date(),
changeFrequency: "monthly",
priority: 0.7,
},
]; ];
const blogRoutes: MetadataRoute.Sitemap = posts.map((post) => ({ const blogRoutes: MetadataRoute.Sitemap = posts.map((post) => ({
@@ -71,5 +81,14 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
priority: 0.7, priority: 0.7,
})); }));
return [...staticRoutes, ...blogRoutes]; const animationSourceRoutes: MetadataRoute.Sitemap = animationSources.map(
(source) => ({
url: `${SITE_URL}${source.lang === "en" ? "/en" : ""}/animation-sources/${source.slug}`,
lastModified: source.date ? new Date(source.date) : new Date(),
changeFrequency: "monthly",
priority: 0.6,
}),
);
return [...staticRoutes, ...blogRoutes, ...animationSourceRoutes];
} }
@@ -0,0 +1,404 @@
"use client";
import Image from "next/image";
import { Icon } from "@iconify/react";
import { useTranslations } from "next-intl";
import React, { useCallback, useEffect, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
import remarkGfm from "remark-gfm";
import { Badge, Card, Typography } from "poyraz-ui/atoms";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "poyraz-ui/molecules";
import { ArticleToc } from "@/components/article-toc";
import type { AnimationSource } from "@/data/animation-sources";
import { Link } from "@/i18n/routing";
import { slugifyMarkdownHeading } from "@/lib/markdown-headings";
type AnimationSourceDetailContentProps = {
source: AnimationSource;
};
function extractText(children: React.ReactNode): string {
if (typeof children === "string" || typeof children === "number") {
return String(children);
}
if (Array.isArray(children)) return children.map(extractText).join("");
if (children && typeof children === "object" && "props" in children) {
return extractText(
(children as React.ReactElement<{ children?: React.ReactNode }>).props
.children,
);
}
return "";
}
async function copyToClipboard(value: string) {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(value);
return;
}
const textarea = document.createElement("textarea");
textarea.value = value;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
textarea.remove();
}
function CopyableCodeBlock({
code,
language,
}: {
code: string;
language: string;
}) {
const t = useTranslations("AnimationSources");
const [copied, setCopied] = useState(false);
const resetTimerRef = useRef<number | null>(null);
useEffect(() => {
return () => {
if (resetTimerRef.current !== null) {
window.clearTimeout(resetTimerRef.current);
}
};
}, []);
const handleCopy = async () => {
try {
await copyToClipboard(code);
setCopied(true);
if (resetTimerRef.current !== null) {
window.clearTimeout(resetTimerRef.current);
}
resetTimerRef.current = window.setTimeout(() => setCopied(false), 1800);
} catch {
setCopied(false);
}
};
const displayLanguage = language || "text";
const highlighterLanguage = language === "prompt" ? "text" : displayLanguage;
return (
<Card className="my-4 overflow-hidden rounded-sm border-border">
<div className="flex h-9 items-center justify-between border-b border-border bg-muted/40 px-3">
<span className="text-[11px] font-medium uppercase text-muted-foreground">
{displayLanguage}
</span>
<TooltipProvider delayDuration={150}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={handleCopy}
className="inline-flex h-7 w-7 cursor-pointer items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
aria-label={copied ? t("copied") : t("copy")}
>
<Icon
icon={copied ? "mdi:check" : "mdi:content-copy"}
width={15}
height={15}
/>
</button>
</TooltipTrigger>
<TooltipContent>{copied ? t("copied") : t("copy")}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<SyntaxHighlighter
language={highlighterLanguage}
style={oneDark}
customStyle={{
margin: 0,
borderRadius: 0,
fontSize: "13px",
lineHeight: "1.65",
}}
wrapLongLines={false}
>
{code}
</SyntaxHighlighter>
</Card>
);
}
function MarkdownImage({ src, alt }: { src?: string; alt?: string }) {
if (!src || !src.startsWith("/")) return null;
return (
<Card className="my-4 overflow-hidden rounded-sm border-border">
<Image
src={src}
alt={alt ?? ""}
width={1200}
height={1200}
unoptimized={src.toLowerCase().endsWith(".gif")}
sizes="(max-width: 768px) 100vw, 720px"
className="h-auto w-full object-contain"
/>
{alt ? (
<Typography
variant="small"
className="block border-t border-border px-3 py-2 text-center text-muted-foreground"
>
{alt}
</Typography>
) : null}
</Card>
);
}
export function AnimationSourceDetailContent({
source,
}: AnimationSourceDetailContentProps) {
const t = useTranslations("AnimationSources");
const progressBarRef = useRef<HTMLDivElement>(null);
const [tocOpen, setTocOpen] = useState(false);
const closeToc = useCallback(() => setTocOpen(false), []);
useEffect(() => {
let rafId = 0;
const update = () => {
rafId = 0;
const bar = progressBarRef.current;
if (!bar) return;
const scrollableHeight =
document.documentElement.scrollHeight -
document.documentElement.clientHeight;
const progress =
scrollableHeight <= 0 ? 100 : (window.scrollY / scrollableHeight) * 100;
bar.style.width = `${Math.min(100, Math.max(0, progress))}%`;
};
const handleScroll = () => {
if (rafId !== 0) return;
rafId = window.requestAnimationFrame(update);
};
window.addEventListener("scroll", handleScroll, { passive: true });
update();
return () => {
window.removeEventListener("scroll", handleScroll);
if (rafId !== 0) window.cancelAnimationFrame(rafId);
};
}, []);
return (
<>
<div className="fixed top-0 left-0 z-50 h-1 w-full bg-border/70">
<div ref={progressBarRef} className="h-full w-0 bg-red-600" />
</div>
<div className="flex gap-4">
<div className="min-w-0 flex-1">
<article className="space-y-6 rounded-sm border border-border p-5 md:p-8">
<Link
href="/animation-sources"
className="inline-flex items-center gap-1.5 rounded-sm border border-border px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground"
>
<Icon icon="mdi:arrow-left" width={15} height={15} />
{t("back")}
</Link>
<header className="space-y-3 border-b border-border pb-5">
<div className="flex flex-wrap items-center gap-2">
<Badge className="rounded-sm">{source.platform}</Badge>
{source.tools.map((tool) => (
<Badge key={tool} variant="outline" className="rounded-sm">
{tool}
</Badge>
))}
</div>
<Typography variant="h2">{source.title}</Typography>
<Typography variant="p" className="text-sm text-muted-foreground">
{source.excerpt}
</Typography>
<Typography variant="small" className="block text-muted-foreground">
{source.author} · {source.date}
</Typography>
</header>
<section className="min-w-0 space-y-5">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
h1: ({ children }) => (
<Typography
id={slugifyMarkdownHeading(extractText(children))}
variant="h2"
className="mt-10 mb-3 border-b border-border pb-3"
>
{children}
</Typography>
),
h2: ({ children }) => (
<Typography
id={slugifyMarkdownHeading(extractText(children))}
variant="h3"
className="mt-8 mb-2 scroll-mt-24 border-b border-border pb-2"
>
{children}
</Typography>
),
h3: ({ children }) => (
<Typography
id={slugifyMarkdownHeading(extractText(children))}
variant="large"
className="mt-6 mb-1 scroll-mt-24 text-foreground"
>
{children}
</Typography>
),
p: ({ node, children }) => {
const containsImage = node?.children.some(
(child) =>
child.type === "element" && child.tagName === "img",
);
if (containsImage) {
return <div className="my-4">{children}</div>;
}
return (
<Typography
variant="p"
className="text-sm leading-7 text-foreground/85"
>
{children}
</Typography>
);
},
ul: ({ children }) => (
<ul className="my-3 list-disc space-y-1.5 pl-5 text-sm leading-7 text-foreground/85">
{children}
</ul>
),
ol: ({ children }) => (
<ol className="my-3 list-decimal space-y-1.5 pl-5 text-sm leading-7 text-foreground/85">
{children}
</ol>
),
a: ({ href, children }) => (
<a
href={href}
target={href?.startsWith("http") ? "_blank" : undefined}
rel={href?.startsWith("http") ? "noopener noreferrer" : undefined}
className="text-red-600 underline decoration-red-600/30 underline-offset-2 transition-colors hover:decoration-red-600"
>
{children}
</a>
),
blockquote: ({ children }) => (
<Card className="my-4 rounded-sm border-border border-l-red-600 bg-muted/30 px-4 py-3">
<div className="text-sm text-muted-foreground">{children}</div>
</Card>
),
hr: () => <div className="my-6 border-t border-border" />,
table: ({ children }) => (
<Card className="my-4 overflow-x-auto rounded-sm border-border">
<table className="min-w-full border-collapse text-sm">
{children}
</table>
</Card>
),
th: ({ children }) => (
<th className="border-b border-border bg-muted/50 px-4 py-2.5 text-left text-xs font-semibold text-muted-foreground">
{children}
</th>
),
td: ({ children }) => (
<td className="border-b border-border px-4 py-2.5 align-top text-sm">
{children}
</td>
),
img: ({ src, alt }) => (
<MarkdownImage
src={typeof src === "string" ? src : undefined}
alt={typeof alt === "string" ? alt : undefined}
/>
),
pre: ({ children }) => <>{children}</>,
code: ({ className, children }) => {
const match = /language-([\w-]+)/.exec(className ?? "");
if (!match) {
return (
<code className="rounded-sm border border-border/60 bg-muted/60 px-1.5 py-0.5 text-[13px] text-red-600">
{children}
</code>
);
}
return (
<CopyableCodeBlock
language={match[1]}
code={String(children).replace(/\n$/, "")}
/>
);
},
}}
>
{source.markdown}
</ReactMarkdown>
</section>
</article>
</div>
<aside className="hidden w-52 shrink-0 lg:block">
<div className="sticky top-24">
<ArticleToc markdown={source.markdown} title={t("toc")} />
</div>
</aside>
</div>
<button
type="button"
onClick={() => setTocOpen(true)}
className="fixed right-4 bottom-6 z-40 flex h-11 w-11 cursor-pointer items-center justify-center rounded-full border border-border bg-background shadow-lg transition-transform hover:scale-105 active:scale-95 lg:hidden"
aria-label={t("toc")}
>
<Icon icon="mdi:table-of-contents" width={22} height={22} className="text-red-600" />
</button>
{tocOpen ? (
<div className="fixed inset-0 z-50 lg:hidden" onClick={closeToc}>
<div className="absolute inset-0 bg-black/40" />
<div
className="absolute right-0 bottom-0 left-0 max-h-[60vh] overflow-y-auto rounded-t-lg border-t border-border bg-background p-5 shadow-2xl"
onClick={(event) => event.stopPropagation()}
>
<div className="mb-3 flex items-center justify-between">
<Typography variant="large">{t("toc")}</Typography>
<button
type="button"
onClick={closeToc}
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label={t("closeToc")}
>
<Icon icon="mdi:close" width={18} height={18} />
</button>
</div>
<ArticleToc
markdown={source.markdown}
title={t("toc")}
onNavigate={closeToc}
/>
</div>
</div>
) : null}
</>
);
}
+81
View File
@@ -0,0 +1,81 @@
import Image from "next/image";
import { Badge, Card, Typography } from "poyraz-ui/atoms";
import { Link } from "@/i18n/routing";
import type { AnimationSource } from "@/data/animation-sources";
type AnimationSourcesContentProps = {
sources: AnimationSource[];
labels: {
title: string;
description: string;
empty: string;
itemCount: string;
};
};
export function AnimationSourcesContent({
sources,
labels,
}: AnimationSourcesContentProps) {
return (
<section className="space-y-5">
<header className="border-b border-border pb-4">
<div className="flex flex-wrap items-end justify-between gap-3">
<div className="max-w-2xl">
<Typography variant="h2">{labels.title}</Typography>
<Typography variant="p" className="mt-1 text-sm text-muted-foreground">
{labels.description}
</Typography>
</div>
<Typography variant="small" className="text-muted-foreground">
{labels.itemCount}
</Typography>
</div>
</header>
{sources.length > 0 ? (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{sources.map((source) => (
<Link
key={`${source.lang}-${source.slug}`}
href={`/animation-sources/${source.slug}`}
data-animation-source-card
className="group min-w-0"
>
<Card className="h-full overflow-hidden rounded-sm border-border p-0 transition-colors group-hover:border-red-600/60">
<div className="relative aspect-square overflow-hidden bg-white">
<Image
src={source.coverImage}
alt=""
fill
unoptimized={source.coverImage.toLowerCase().endsWith(".gif")}
sizes="(max-width: 640px) 50vw, 280px"
className="object-cover transition-transform duration-300 group-hover:scale-[1.02] motion-reduce:transition-none motion-reduce:group-hover:scale-100"
/>
</div>
<div className="space-y-2 border-t border-border p-3">
<Badge variant="outline" className="rounded-sm">
{source.platform}
</Badge>
<Typography
variant="large"
data-animation-source-title
className="line-clamp-2 min-h-10 text-sm leading-5 sm:text-base"
>
{source.title}
</Typography>
</div>
</Card>
</Link>
))}
</div>
) : (
<Card className="rounded-sm border-border p-6 text-center">
<Typography variant="p" className="text-muted-foreground">
{labels.empty}
</Typography>
</Card>
)}
</section>
);
}
+107
View File
@@ -0,0 +1,107 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Typography } from "poyraz-ui/atoms";
import { parseMarkdownHeadings } from "@/lib/markdown-headings";
type ArticleTocProps = {
markdown: string;
title: string;
onNavigate?: () => void;
};
export function ArticleToc({ markdown, title, onNavigate }: ArticleTocProps) {
const headings = useMemo(() => parseMarkdownHeadings(markdown), [markdown]);
const [activeId, setActiveId] = useState("");
const rafRef = useRef(0);
const handleClick = useCallback(
(id: string) => {
const target = document.getElementById(id);
if (!target) return;
target.scrollIntoView({ behavior: "smooth", block: "start" });
setActiveId(id);
onNavigate?.();
},
[onNavigate],
);
useEffect(() => {
if (headings.length === 0) return;
const headingElements = headings
.map((heading) => ({
id: heading.id,
element: document.getElementById(heading.id),
}))
.filter((item): item is { id: string; element: HTMLElement } =>
Boolean(item.element),
);
if (headingElements.length === 0) return;
const updateActive = () => {
let current = headingElements[0].id;
for (const item of headingElements) {
if (item.element.getBoundingClientRect().top <= 120) {
current = item.id;
continue;
}
break;
}
setActiveId(current);
};
const observer = new IntersectionObserver(
() => {
cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(updateActive);
},
{
rootMargin: "-120px 0px -65% 0px",
threshold: [0, 1],
},
);
for (const item of headingElements) {
observer.observe(item.element);
}
const timer = window.setTimeout(updateActive, 200);
return () => {
observer.disconnect();
cancelAnimationFrame(rafRef.current);
window.clearTimeout(timer);
};
}, [headings]);
if (headings.length < 2) return null;
return (
<nav aria-label={title} className="space-y-1">
<Typography variant="small" className="mb-2 font-semibold text-foreground">
{title}
</Typography>
{headings.map((heading, index) => (
<button
key={`${heading.id}-${index}`}
type="button"
onClick={() => handleClick(heading.id)}
className={[
"block w-full cursor-pointer truncate border-l-2 text-left text-xs leading-relaxed transition-colors",
heading.level === 3 ? "pl-5" : "pl-3",
activeId === heading.id
? "border-red-600 text-red-600"
: "border-transparent text-muted-foreground hover:text-foreground",
].join(" ")}
>
{heading.text}
</button>
))}
</nav>
);
}
+11 -2
View File
@@ -60,6 +60,15 @@ function getNavLinkClass(isActive: boolean) {
].join(" "); ].join(" ");
} }
function getDesktopDropdownTriggerClass(isActive: boolean) {
return [
"relative inline-flex h-7 shrink-0 cursor-pointer items-center justify-center gap-1 whitespace-nowrap rounded-sm px-2.5 text-xs font-medium outline-none",
"transition-[color,background-color] duration-[var(--poyraz-motion-duration-fast)] ease-[var(--poyraz-motion-ease-out)]",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
isActive ? "text-foreground" : "text-muted-foreground hover:text-foreground",
].join(" ");
}
type ThemeToggleProps = { type ThemeToggleProps = {
theme: ThemeMode; theme: ThemeMode;
onThemeChange: (theme: ThemeMode) => void; onThemeChange: (theme: ThemeMode) => void;
@@ -237,13 +246,13 @@ export function SiteNavbar({ theme, onThemeChange }: SiteNavbarProps) {
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button <button
type="button" type="button"
className={`${getNavLinkClass( className={getDesktopDropdownTriggerClass(
group.items.some( group.items.some(
(groupItem) => (groupItem) =>
!groupItem.external && !groupItem.external &&
isActiveLink(groupItem.href.split("?")[0]), isActiveLink(groupItem.href.split("?")[0]),
), ),
)} h-9 cursor-pointer items-center gap-1 px-2.5`} )}
> >
<span>{t(group.id)}</span> <span>{t(group.id)}</span>
<Icon icon="mdi:chevron-down" width={14} height={14} /> <Icon icon="mdi:chevron-down" width={14} height={14} />
@@ -0,0 +1,440 @@
---
title: "How to Build an AI Avatar That Follows the Pointer"
slug: "poyraz-cursor-portrait"
excerpt: "Build the effect from start to finish with the prompts, Wiro AI and MiniMax H3 video workflow, FFmpeg preparation, and React integration I used."
coverImage: "/animation-sources/poyraz-cursor-portrait/avatar-mouse-follow.gif"
platform: "Web"
tools:
- "Wiro AI"
- "MiniMax H3"
- "FFmpeg"
- "React"
date: "2026-08-29"
author: "Poyraz Avsever"
lang: "en"
---
The portrait at the bottom-right is not playing like a normal video. It remains paused while the pointer's vertical position controls the video timeline. At the bottom of the screen the portrait looks down-left, in the middle it looks horizontally left, and at the top it looks up-left.
I generated the video with the **MiniMax H3** model through **Wiro AI** in a **1:1 square format**. I then prepared it for frequent seeking with FFmpeg and connected its `currentTime` to the pointer's Y position in React.
Every prompt on this page is copyable and can be adapted to your portrait, avatar, or brand character.
## How to use the `[[...]]` fields
Double square brackets mark values you must replace. Do not leave `[[OUTFIT]]` in the final prompt; replace it with a concrete value such as `plain red polo shirt`.
| Variable | What it means | Value in this project |
| --- | --- | --- |
| `[[SUBJECT]]` | Person or character | young male software creator |
| `[[OUTFIT]]` | Clothing | plain red polo shirt |
| `[[BACKGROUND_COLOR]]` | Flat background | pure white, `#FFFFFF` |
| `[[EXPRESSION]]` | Fixed expression | calm, natural, neutral |
| `[[ASPECT_RATIO]]` | Generation ratio | `1:1` |
| `[[HEAD_DIRECTION]]` | Fixed horizontal angle | about 60 degrees left |
| `[[VIDEO_PATH]]` | Public video path | `/media/cursor-portrait/poyraz-bottom-right.mp4` |
| `[[POSTER_PATH]]` | Public poster path | `/media/cursor-portrait/poyraz-bottom-right-poster.webp` |
| `[[FRAMEWORK]]` | Application stack | Next.js, React, TypeScript |
| `[[STYLING_SYSTEM]]` | Styling stack | Tailwind CSS |
Search for every `[[...]]` field before submitting a prompt and make sure no unresolved variable remains.
## How the effect works
The reliable way to control a single video in real time is to treat it as a short **motion-control plate**, not as an autoplaying clip.
The four-second timeline in this project:
1. `0.000.25`: hold the down-left pose.
2. `0.253.75`: move from down-left to up-left.
3. Around `2.00`: reach the neutral horizontal-left pose.
4. `3.754.00`: hold the up-left pose.
Moving the pointer vertically scrubs this active range forward or backward. Pointer X is intentionally ignored because the generated video contains only one controlled motion axis.
> A single video is reliable only along the motion axis it contains. For true horizontal and vertical tracking, use a consistent 3×3 set of directional stills instead of inventing a second axis in code.
## Production workflow
1. Select a clear, front-facing identity reference.
2. Prepare a consistent 1:1 master frame with fixed clothing, light, and background.
3. Upload the master frame to Wiro AI and generate the motion with MiniMax H3.
4. Regenerate with the repair prompt if the face, camera, or background drifts.
5. Convert the result into a seek-friendly 720×720 H.264 web asset.
6. Map pointer Y to the video's active time range.
7. Test desktop, reduced-motion, dark-theme, and mobile behavior separately.
## 1. Master frame prompt
Upload a clear identity reference to your image-generation tool and replace every `[[...]]` field first.
```prompt
Use the uploaded image only as the identity reference for [[SUBJECT]].
Create a new photorealistic, production-ready 1:1 studio portrait for an
interactive website animation. Preserve the exact recognizable identity,
facial proportions, skin tone, hairstyle, hairline, eyebrows, eye shape,
nose, lips, jawline, age, and overall appearance.
Composition:
- Square [[ASPECT_RATIO]] frame.
- Medium close-up from [[CROP_POINT]] upward.
- Keep the full head, hair, ears, neck, shoulders, and visible upper torso
safely inside the frame.
- Keep comfortable negative space around the hair and shoulders.
- The shoulders remain stable and the head is turned approximately
[[HEAD_DIRECTION]].
- Expression: [[EXPRESSION]].
- Outfit: [[OUTFIT]].
Background and light:
- Perfectly flat, seamless [[BACKGROUND_COLOR]] background.
- No gradient, texture, horizon line, furniture, props, text, watermark,
logo, border, or visible cast shadow.
- Soft, bright studio lighting with natural skin texture.
- Keep hair, ears, face, shoulders, and clothing edges clean.
Continuity constraints:
- Do not beautify, age, de-age, stylize, or reinterpret the person.
- Do not change facial hair, outfit, accessories, body proportions, or light.
- Do not crop the hair, ears, shoulders, or upper torso.
- Generate one person and one clean master frame only.
```
Values used for this implementation:
```text
[[SUBJECT]] = a young male software creator
[[ASPECT_RATIO]] = 1:1
[[CROP_POINT]] = mid-torso
[[HEAD_DIRECTION]] = 60 degrees toward screen-left
[[EXPRESSION]] = calm, natural, neutral expression
[[OUTFIT]] = plain red polo shirt
[[BACKGROUND_COLOR]] = pure white (#FFFFFF)
```
## 2. Wiro AI / MiniMax H3 video prompt
Use the master frame as the image reference in Wiro AI with the MiniMax H3 model. The goal is a technical plate that works frame by frame, not a cinematic scene.
```prompt
Animate the uploaded 1:1 master frame into a precise four-second motion-control
plate for an interactive website portrait. Preserve the exact identity, face,
hairstyle, red polo shirt, body proportions, lighting, colors, square framing,
and pure white background from the reference image.
Output:
- Duration: exactly 4.0 seconds.
- Aspect ratio: 1:1.
- One continuous shot with a completely locked, eye-level camera.
- No zoom, crop change, pan, tilt, dolly, reframing, or camera shake.
- No speech and no audio-dependent movement.
Head direction:
- Keep the subject turned approximately 60 degrees toward screen-left for
the entire video.
- The horizontal head angle must not change.
- Never turn toward the camera and never rotate into a full side profile.
Exact motion timeline:
- 0.000.25 seconds: hold a clean down-left gaze and head-tilt pose.
- 0.253.75 seconds: move smoothly and continuously from down-left to up-left.
- At exactly 2.00 seconds: reach a neutral horizontal-left gaze.
- 3.754.00 seconds: hold the final up-left pose perfectly still.
Movement rules:
- Only the eyes and the minimum natural head/neck tilt required for the
vertical gaze may move.
- Shoulders, torso, arms, clothing, body position, head scale, and horizontal
head angle remain fixed.
- Keep the mouth closed and motionless.
- No talking, smiling, eyebrow movement, nodding, leaning, body sway,
breathing motion, or secondary gesture.
- Movement must be slow, linear, anatomically coherent, and usable when
scrubbed both forward and backward.
Continuity:
- Preserve the same recognizable face in every frame.
- Keep hair volume, hairline, ears, nose, jaw, skin texture, clothing folds,
and lighting stable.
- No face drift, morphing, warped anatomy, flicker, or changing expression.
- Keep the background perfectly uniform pure white (#FFFFFF) in every frame.
This is not a cinematic scene. It is a deterministic frame-scrubbing asset
for a website and every intermediate frame must work as a clean still image.
```
Inspect the middle frames as carefully as the endpoints. Face shape, ears, hairline, and clothing edges must remain stable throughout the MiniMax H3 output.
## 3. Repair prompt
Describe the failed generation precisely in `[[OBSERVED_PROBLEMS]]`.
```prompt
Regenerate this clip as a strict technical motion plate. The previous result
is unusable because: [[OBSERVED_PROBLEMS]].
Lock every property except the intended vertical gaze and head-tilt movement:
- preserve the exact identity and facial proportions in every frame;
- keep the horizontal head angle fixed at approximately 60 degrees left;
- fixed camera, crop, focal length, scale, head position, shoulders, torso,
arms, outfit, expression, lighting, and background;
- one slow linear movement from down-left to up-left;
- neutral horizontal-left pose at exactly two seconds;
- closed and motionless mouth;
- no speech, smile, blink during movement, eyebrow motion, body sway,
zoom, parallax, lighting shift, background flicker, face morphing,
hair change, ear deformation, or new objects;
- perfectly uniform pure white (#FFFFFF) background.
This clip will be paused and scrubbed frame by frame. Every intermediate frame
must remain anatomically coherent and visually consistent with the reference.
```
Example problem description:
```text
[[OBSERVED_PROBLEMS]] = the face changes near the final pose, the shoulders
move with the head, and the white background flickers between frames
```
## 4. Preparing the video for the web
AI video can play directly in a browser, but codec and keyframe interval matter when `currentTime` changes frequently. I prepared a 720×720, 30 FPS, silent H.264 file with every frame encoded as a keyframe.
```bash
ffmpeg -i INPUT.mp4 \
-vf "scale=720:720:force_original_aspect_ratio=decrease,pad=720:720:(ow-iw)/2:(oh-ih)/2:color=white,fps=30" \
-an -c:v libx264 -preset slow -crf 20 -pix_fmt yuv420p \
-g 1 -keyint_min 1 -sc_threshold 0 -movflags +faststart \
public/media/cursor-portrait/poyraz-bottom-right.mp4
```
Key decisions:
- `-an` removes audio completely.
- `yuv420p` improves Safari and Chromium compatibility.
- `faststart` moves MP4 metadata to the beginning.
- `-g 1` makes every frame independently seekable.
- `scale + pad` preserves proportions on a square white surface.
### Media optimization agent prompt
```prompt
Prepare [[INPUT_VIDEO_PATH]] as a web motion-control plate that will be scrubbed
forward and backward from pointer movement. Never overwrite the source file.
Outputs:
- Video: [[VIDEO_PATH]]
- Poster: [[POSTER_PATH]]
- Exact duration: 4.00 seconds
- Starting hold: 0.000.25
- Active motion: 0.253.75
- Final hold: 3.754.00
- Resolution: 720×720
- Frame rate: 30 FPS
- Codec: H.264 MP4, libx264, yuv420p
- Settings: preset slow, CRF 20, faststart, no audio
- Every frame, or at most every second frame, must be a keyframe
Do not distort the aspect ratio. Use #FFFFFF padding when needed. Do not crop
hair, face, ears, shoulders, or clothing. Verify duration, resolution, FPS,
codec, and file size. Visually inspect the first, middle, and final frames.
Do not modify unrelated project files.
```
## 5. Mapping pointer Y to video time
Pointer Y is `0` at the viewport top and `window.innerHeight` at the bottom. Invert and clamp it to `01`, then map it to the active video range.
```ts
const TOTAL_DURATION = 4;
const ACTIVE_START = 0.25;
const ACTIVE_END = 3.75;
const DEFAULT_TIME = 2;
const SMOOTHING = 0.12;
function clamp(value: number, minimum: number, maximum: number) {
return Math.min(Math.max(value, minimum), maximum);
}
function mapPointerYToTime(pointerY: number, viewportHeight: number) {
if (viewportHeight <= 0) return DEFAULT_TIME;
const progress = clamp(1 - pointerY / viewportHeight, 0, 1);
return ACTIVE_START + progress * (ACTIVE_END - ACTIVE_START);
}
```
| Pointer position | Progress | Video time | Gaze |
| --- | ---: | ---: | --- |
| Viewport bottom | `0` | `0.25` | down-left |
| Viewport middle | `0.5` | `2.00` | horizontal-left |
| Viewport top | `1` | `3.75` | up-left |
`pointerX` never enters this formula, so horizontal pointer movement does not change the frame.
## 6. Smooth scrubbing in React
Keep high-frequency values in refs instead of updating React state for every pointer event. A single `requestAnimationFrame` loop damps the current value toward the target.
```ts
const pointerYRef = useRef<number | null>(null);
const targetTimeRef = useRef(DEFAULT_TIME);
const currentTimeRef = useRef(DEFAULT_TIME);
const rafIdRef = useRef<number | null>(null);
function animate() {
const difference = targetTimeRef.current - currentTimeRef.current;
currentTimeRef.current += difference * SMOOTHING;
const video = videoRef.current;
if (video && Math.abs(video.currentTime - currentTimeRef.current) > 1 / 120) {
video.currentTime = currentTimeRef.current;
}
if (Math.abs(difference) > 0.002) {
rafIdRef.current = requestAnimationFrame(animate);
} else {
rafIdRef.current = null;
}
}
function handlePointerMove(event: PointerEvent) {
if (event.pointerType !== "mouse") return;
pointerYRef.current = event.clientY;
targetTimeRef.current = mapPointerYToTime(
event.clientY,
window.innerHeight,
);
if (rafIdRef.current === null) {
rafIdRef.current = requestAnimationFrame(animate);
}
}
```
Production code must also handle metadata readiness, optional video priming, tab visibility, and complete listener cleanup.
## 7. Ready-to-use coding-agent prompt
Replace the bracketed values and use this with a coding agent in an existing frontend project.
```prompt
Add a reusable `PointerPortraitFollower` component to the existing [[FRAMEWORK]]
project. It must stay at the bottom-right of the viewport and react only to the
pointer's Y position. Styling system: [[STYLING_SYSTEM]].
Assets:
- Video: [[VIDEO_PATH]]
- Poster: [[POSTER_PATH]]
System constants:
- TOTAL_DURATION = 4
- ACTIVE_START = 0.25
- ACTIVE_END = 3.75
- DEFAULT_TIME = 2
- SMOOTHING = 0.12
Behavior:
- Keep the video paused; never autoplay it normally.
- Use pointerY only. pointerX must never affect video timing.
- progress = clamp(1 - pointerY / window.innerHeight, 0, 1)
- targetTime = ACTIVE_START + progress * (ACTIVE_END - ACTIVE_START)
- Use a global pointermove listener without React state updates per event.
- Store pointerY, targetTime, and currentTime in refs.
- Apply damping in one requestAnimationFrame loop.
- Limit seeks to about 3060 Hz and skip tiny time differences.
- Return smoothly to the neutral 2.00-second pose when the pointer leaves the
window or the window loses focus.
Video element:
- muted, playsInline, preload="auto", no controls, no autoplay
- seek to 2.00 after loadedmetadata
- prime muted playback briefly on the first real pointer move only if required
- show the poster instead of a broken media icon after an asset error
Placement:
- position: fixed; right: [[RIGHT_OFFSET]]; bottom: [[BOTTOM_OFFSET]]
- width: [[DESKTOP_WIDTH]]; aspect-ratio: 1 / 1; z-index: [[Z_INDEX]]
- object-fit: contain; background: [[BACKGROUND_COLOR]]
- pointer-events: none; user-select: none; aria-hidden: true
- no border, radius, shadow, or horizontal mirroring
Responsive and lifecycle:
- disable animation on pointer: coarse and narrow viewports
- never interpret touch as mouse tracking
- honor prefers-reduced-motion
- do not block CTA, link, or menu interaction
- never access window/document during SSR
- stop RAF and seeking while the tab is hidden
- clean pointermove, pointerleave, blur, resize, visibilitychange, and RAF on
unmount; never start multiple RAF loops
Separate mapping and clamp into pure typed helpers. Add boundary tests when a
test setup exists. Do not add a heavy animation dependency. Run build,
typecheck, lint, and existing tests after implementation.
```
Values from this implementation:
```text
[[FRAMEWORK]] = Next.js App Router, React, TypeScript
[[STYLING_SYSTEM]] = Tailwind CSS and Poyraz UI
[[VIDEO_PATH]] = /media/cursor-portrait/poyraz-bottom-right.mp4
[[POSTER_PATH]] = /media/cursor-portrait/poyraz-bottom-right-poster.webp
[[RIGHT_OFFSET]] = 24px
[[BOTTOM_OFFSET]] = 0px
[[DESKTOP_WIDTH]] = clamp(110px, 11vw, 170px)
[[Z_INDEX]] = 40
[[BACKGROUND_COLOR]] = #FFFFFF
```
## 8. Mobile, accessibility, and fallback
This effect is meaningful on desktop with a mouse. Treating touch movement as pointer tracking hurts usability and wastes decoding work.
My choices:
- Do not render the component on `pointer: coarse` devices.
- Hide it completely below `840px`.
- Honor `prefers-reduced-motion`.
- Hide the white-background asset in dark mode.
- Keep it decorative with `pointer-events: none` and `aria-hidden="true"`.
- Show the poster if video loading fails.
## 9. Quality checklist
### Video
- Is it the same person in the first, middle, and final frames?
- Does the head stay turned about 60 degrees left?
- Are the poses down-left, horizontal-left, and up-left in the correct order?
- Do hair, ears, jaw, and facial features remain stable?
- Do shoulders and clothing stay still?
- Does the camera, light, or white background flicker?
- Does the motion remain natural when scrubbed backward?
### Web
- Does the video remain paused before pointer input?
- Does it scrub in the correct direction on vertical movement?
- Does horizontal-only movement leave the frame unchanged?
- Is there a seek queue or visible lag during fast movement?
- Does the portrait return to neutral after leaving the window?
- Are links and CTA controls still clickable?
- Is animation disabled on mobile and reduced-motion?
- Does the poster appear after a video error?
- Are listeners and RAF cleaned up after navigation?
## Adapt it to your project
Five steps are enough to reuse the system:
1. Replace every `[[...]]` variable for your character.
2. Produce a consistent 1:1 master frame on a flat background.
3. Describe only one intended motion axis in the MiniMax H3 prompt.
4. Map that same axis to the active video range.
5. Connect the optimized video and poster to the component.
The main rule is simple: do not invent motion in code that does not exist in the generated video. Treating the AI output as a controlled motion plate makes the effect more natural, deterministic, and testable.
@@ -0,0 +1,448 @@
---
title: "Fareyi Takip Eden AI Avatar Nasıl Yapılır?"
slug: "poyraz-cursor-portrait"
excerpt: "Wiro AI üzerinde MiniMax H3 ile ürettiğim 1:1 avatar videosunu, promptlardan FFmpeg optimizasyonuna ve React entegrasyonuna kadar adım adım oluşturun."
coverImage: "/animation-sources/poyraz-cursor-portrait/avatar-mouse-follow.gif"
platform: "Web"
tools:
- "Wiro AI"
- "MiniMax H3"
- "FFmpeg"
- "React"
date: "2026-08-29"
author: "Poyraz Avsever"
lang: "tr"
---
Bu efektte sağ alttaki portre normal bir video gibi oynatılmıyor. Video duraklatılmış halde tutuluyor; farenin ekrandaki dikey konumu videonun zaman çizelgesine bağlanıyor. Fare aşağıdayken portre aşağı-sola, ortadayken yatay-sola, yukarıdayken yukarı-sola bakıyor.
Videoyu **Wiro AI** üzerinden **MiniMax H3** modeliyle, **1:1 kare formatta** ürettim. Sonrasında videoyu FFmpeg ile sık ileri-geri sarılmaya uygun hale getirip React içinde `currentTime` üzerinden kontrol ettim.
Bu sayfadaki promptları doğrudan kopyalayabilir ve kendi portreniz, avatarınız veya marka karakteriniz için uyarlayabilirsiniz.
## Promptlardaki `[[...]]` alanları nasıl kullanılır?
Promptlarda gördüğünüz çift köşeli parantezler doldurulması gereken değişken alanlardır. Örneğin `[[OUTFIT]]` ifadesini promptta bırakmak yerine `plain red polo shirt` gibi kendi değerinizi yazmalısınız.
| Değişken | Ne yazılmalı? | Bu projedeki değer |
| --- | --- | --- |
| `[[SUBJECT]]` | Kişi veya karakter tanımı | 20 yaşında erkek içerik üreticisi |
| `[[OUTFIT]]` | Kıyafet | düz kırmızı polo tişört |
| `[[BACKGROUND_COLOR]]` | Düz arka plan | saf beyaz, `#FFFFFF` |
| `[[EXPRESSION]]` | Sabit yüz ifadesi | doğal ve nötr |
| `[[ASPECT_RATIO]]` | Üretim oranı | `1:1` |
| `[[HEAD_DIRECTION]]` | Başın sabit yatay yönü | yaklaşık 60 derece sola |
| `[[VIDEO_PATH]]` | Web video yolu | `/media/cursor-portrait/poyraz-bottom-right.mp4` |
| `[[POSTER_PATH]]` | Poster yolu | `/media/cursor-portrait/poyraz-bottom-right-poster.webp` |
| `[[FRAMEWORK]]` | Kullanılan teknoloji | Next.js, React, TypeScript |
| `[[STYLING_SYSTEM]]` | Stil sistemi | Tailwind CSS |
Bir promptu kullanmadan önce içindeki tüm `[[...]]` alanlarını aratın. Projeniz için karşılığı olmayan bir değişken kalmamalı.
## Sistem nasıl çalışıyor?
Tek bir videoyu gerçek zamanlı kontrol etmenin en stabil yolu videoyu sürekli oynatmak değil, onu kısa bir **hareket plakası** olarak kullanmaktır.
Bu uygulamadaki dört saniyelik zaman çizelgesi:
1. `0.000.25`: aşağı-sola bakış pozu sabit tutulur.
2. `0.253.75`: bakış aşağıdan yukarıya doğru ilerler.
3. Yaklaşık `2.00`: sola doğru nötr ve yatay bakış oluşur.
4. `3.754.00`: yukarı-sola bakış pozu sabit tutulur.
Fare aşağı-yukarı hareket ettikçe video bu aktif aralıkta ileri veya geri sarılır. Yatay fare konumu bu sürümde kullanılmaz; böylece yapay zeka videosunda bulunmayan ikinci bir hareket ekseni uydurulmaz.
> Tek video yalnızca üretilmiş hareket ekseninde güvenilir sonuç verir. Gerçek yatay ve dikey takip gerekiyorsa farklı yönlere ait tutarlı karelerden oluşan 3×3 bir sistem daha doğru yaklaşımdır.
## Üretim akışı
1. Net, önden çekilmiş bir referans fotoğraf seçin.
2. Kimliği, kıyafeti, ışığı ve beyaz arka planı sabitleyen 1:1 master kareyi hazırlayın.
3. Master kareyi Wiro AI'a yükleyip MiniMax H3 ile hareket videosunu üretin.
4. Kimlik kayması, kamera hareketi veya arka plan titreşimi varsa onarım promptuyla yeniden üretin.
5. Videoyu 720×720 H.264 web asset'ine dönüştürün.
6. Videonun `currentTime` değerini farenin Y konumuna bağlayın.
7. Masaüstü, reduced-motion, koyu tema ve mobil davranışlarını ayrı ayrı test edin.
## 1. Master kare promptu
Referans fotoğrafı kullandığınız görsel üretim aracına yükleyin. Aşağıdaki promptta önce tüm `[[...]]` alanlarını değiştirin.
```prompt
Use the uploaded image only as the identity reference for [[SUBJECT]].
Create a new photorealistic, production-ready 1:1 studio portrait for an
interactive website animation. Preserve the exact recognizable identity,
facial proportions, skin tone, hairstyle, hairline, eyebrows, eye shape,
nose, lips, jawline, age, and overall appearance.
Composition:
- Square [[ASPECT_RATIO]] frame.
- Medium close-up from [[CROP_POINT]] upward.
- Keep the full head, hair, ears, neck, shoulders, and visible upper torso
safely inside the frame.
- Keep comfortable negative space around the hair and shoulders.
- The shoulders remain stable and the head is turned approximately
[[HEAD_DIRECTION]].
- Expression: [[EXPRESSION]].
- Outfit: [[OUTFIT]].
Background and light:
- Perfectly flat, seamless [[BACKGROUND_COLOR]] background.
- No gradient, texture, horizon line, furniture, props, text, watermark,
logo, border, or visible cast shadow.
- Soft, bright studio lighting with natural skin texture.
- Keep hair, ears, face, shoulders, and clothing edges clean.
Continuity constraints:
- Do not beautify, age, de-age, stylize, or reinterpret the person.
- Do not change facial hair, outfit, accessories, body proportions, or light.
- Do not crop the hair, ears, shoulders, or upper torso.
- Generate one person and one clean master frame only.
```
Bu uygulama için kullandığım değerler:
```text
[[SUBJECT]] = a young male software creator
[[ASPECT_RATIO]] = 1:1
[[CROP_POINT]] = mid-torso
[[HEAD_DIRECTION]] = 60 degrees toward screen-left
[[EXPRESSION]] = calm, natural, neutral expression
[[OUTFIT]] = plain red polo shirt
[[BACKGROUND_COLOR]] = pure white (#FFFFFF)
```
## 2. Wiro AI / MiniMax H3 video promptu
Master kareyi Wiro AI üzerinde MiniMax H3 modeline referans olarak verin. Bu promptun amacı sinematik bir sahne değil, kare kare durdurulup sarılabilecek teknik bir hareket üretmektir.
```prompt
Animate the uploaded 1:1 master frame into a precise four-second motion-control
plate for an interactive website portrait. Preserve the exact identity, face,
hairstyle, red polo shirt, body proportions, lighting, colors, square framing,
and pure white background from the reference image.
Output:
- Duration: exactly 4.0 seconds.
- Aspect ratio: 1:1.
- One continuous shot with a completely locked, eye-level camera.
- No zoom, crop change, pan, tilt, dolly, reframing, or camera shake.
- No speech and no audio-dependent movement.
Head direction:
- Keep the subject turned approximately 60 degrees toward screen-left for
the entire video.
- The horizontal head angle must not change.
- Never turn toward the camera and never rotate into a full side profile.
Exact motion timeline:
- 0.000.25 seconds: hold a clean down-left gaze and head-tilt pose.
- 0.253.75 seconds: move smoothly and continuously from down-left to up-left.
- At exactly 2.00 seconds: reach a neutral horizontal-left gaze.
- 3.754.00 seconds: hold the final up-left pose perfectly still.
Movement rules:
- Only the eyes and the minimum natural head/neck tilt required for the
vertical gaze may move.
- Shoulders, torso, arms, clothing, body position, head scale, and horizontal
head angle remain fixed.
- Keep the mouth closed and motionless.
- No talking, smiling, eyebrow movement, nodding, leaning, body sway,
breathing motion, or secondary gesture.
- Movement must be slow, linear, anatomically coherent, and usable when
scrubbed both forward and backward.
Continuity:
- Preserve the same recognizable face in every frame.
- Keep hair volume, hairline, ears, nose, jaw, skin texture, clothing folds,
and lighting stable.
- No face drift, morphing, warped anatomy, flicker, or changing expression.
- Keep the background perfectly uniform pure white (#FFFFFF) in every frame.
This is not a cinematic scene. It is a deterministic frame-scrubbing asset
for a website and every intermediate frame must work as a clean still image.
```
MiniMax H3 çıktısını değerlendirirken yalnızca ilk ve son kareye bakmayın. Orta karede yüzün, kulağın, saç çizgisinin ve tişört kenarlarının bozulmadığını da kontrol edin.
## 3. Sorunlu videoyu yeniden üretme promptu
İlk üretimde yüz kayması veya kamera hareketi varsa problemi `[[OBSERVED_PROBLEMS]]` alanına açıkça yazın.
```prompt
Regenerate this clip as a strict technical motion plate. The previous result
is unusable because: [[OBSERVED_PROBLEMS]].
Lock every property except the intended vertical gaze and head-tilt movement:
- preserve the exact identity and facial proportions in every frame;
- keep the horizontal head angle fixed at approximately 60 degrees left;
- fixed camera, crop, focal length, scale, head position, shoulders, torso,
arms, outfit, expression, lighting, and background;
- one slow linear movement from down-left to up-left;
- neutral horizontal-left pose at exactly two seconds;
- closed and motionless mouth;
- no speech, smile, blink during movement, eyebrow motion, body sway,
zoom, parallax, lighting shift, background flicker, face morphing,
hair change, ear deformation, or new objects;
- perfectly uniform pure white (#FFFFFF) background.
This clip will be paused and scrubbed frame by frame. Every intermediate frame
must remain anatomically coherent and visually consistent with the reference.
```
Örnek problem tanımı:
```text
[[OBSERVED_PROBLEMS]] = the face changes near the final pose, the shoulders
move with the head, and the white background flickers between frames
```
## 4. Videoyu web için hazırlama
Yapay zeka videosu doğrudan tarayıcıya konabilir; ancak sık `currentTime` güncellemelerinde codec ve keyframe aralığı büyük fark yaratır. Ben çıktıyı 720×720, 30 FPS, sessiz H.264 ve her kare keyframe olacak şekilde hazırladım.
```bash
ffmpeg -i INPUT.mp4 \
-vf "scale=720:720:force_original_aspect_ratio=decrease,pad=720:720:(ow-iw)/2:(oh-ih)/2:color=white,fps=30" \
-an -c:v libx264 -preset slow -crf 20 -pix_fmt yuv420p \
-g 1 -keyint_min 1 -sc_threshold 0 -movflags +faststart \
public/media/cursor-portrait/poyraz-bottom-right.mp4
```
Buradaki kritik tercihler:
- `-an`: sesi tamamen kaldırır.
- `yuv420p`: Safari ve Chromium uyumluluğunu artırır.
- `faststart`: MP4 metadata'sını dosyanın başına taşır.
- `-g 1`: her kareyi keyframe yaparak sık seek işlemini hızlandırır.
- `scale + pad`: görüntüyü esnetmeden 1:1 beyaz yüzeyde tutar.
### Medya optimizasyonu için coding-agent promptu
```prompt
Projeye eklediğim [[INPUT_VIDEO_PATH]] videosunu fare konumuyla ileri ve geri
sarılacak bir web hareket plakası olarak hazırla.
Kaynak dosyaya dokunma veya üzerine yazma.
Hedefler:
- Video: [[VIDEO_PATH]]
- Poster: [[POSTER_PATH]]
- Tam süre: 4.00 saniye
- Başlangıç pozu: 0.000.25
- Aktif hareket: 0.253.75
- Son poz: 3.754.00
- Çözünürlük: 720×720
- FPS: 30
- Codec: H.264 MP4, libx264, yuv420p
- Ayarlar: preset slow, CRF 20, faststart, ses yok
- Sık currentTime değişimi için her kare veya en fazla iki karede bir keyframe
En-boy oranını bozma. Gerekirse #FFFFFF padding kullan. Saç, yüz, kulak,
omuz veya kıyafeti kesme. İşlemden sonra süre, çözünürlük, FPS, codec ve dosya
boyutunu doğrula; ilk, orta ve son kareyi görsel olarak kontrol et. Alakasız
proje dosyalarına dokunma.
```
## 5. Mouse Y değerini video zamanına eşleme
Farenin Y konumu ekranın üstünde `0`, altında `window.innerHeight` değerindedir. Önce bu değeri ters çevirip `01` aralığına sıkıştırıyorum, ardından videonun aktif zaman aralığına map ediyorum.
```ts
const TOTAL_DURATION = 4;
const ACTIVE_START = 0.25;
const ACTIVE_END = 3.75;
const DEFAULT_TIME = 2;
const SMOOTHING = 0.12;
function clamp(value: number, minimum: number, maximum: number) {
return Math.min(Math.max(value, minimum), maximum);
}
function mapPointerYToTime(pointerY: number, viewportHeight: number) {
if (viewportHeight <= 0) return DEFAULT_TIME;
const progress = clamp(1 - pointerY / viewportHeight, 0, 1);
return ACTIVE_START + progress * (ACTIVE_END - ACTIVE_START);
}
```
Eşleme sonucu:
| Fare konumu | Progress | Video zamanı | Bakış |
| --- | ---: | ---: | --- |
| Ekranın altı | `0` | `0.25` | aşağı-sola |
| Ekranın ortası | `0.5` | `2.00` | yatay-sola |
| Ekranın üstü | `1` | `3.75` | yukarı-sola |
`pointerX` bu hesaplamaya hiç girmez. Fare yalnızca sağa veya sola hareket ettiğinde video karesi değişmez.
## 6. Akıcı scrub için React yaklaşımı
Her pointer event'inde React state güncellemek yerine yüksek frekanslı değerleri ref içinde tutun. Tek bir `requestAnimationFrame` döngüsü mevcut zamanı hedef zamana yaklaştırsın.
```ts
const pointerYRef = useRef<number | null>(null);
const targetTimeRef = useRef(DEFAULT_TIME);
const currentTimeRef = useRef(DEFAULT_TIME);
const rafIdRef = useRef<number | null>(null);
function animate() {
const difference = targetTimeRef.current - currentTimeRef.current;
currentTimeRef.current += difference * SMOOTHING;
const video = videoRef.current;
if (video && Math.abs(video.currentTime - currentTimeRef.current) > 1 / 120) {
video.currentTime = currentTimeRef.current;
}
if (Math.abs(difference) > 0.002) {
rafIdRef.current = requestAnimationFrame(animate);
} else {
rafIdRef.current = null;
}
}
function handlePointerMove(event: PointerEvent) {
if (event.pointerType !== "mouse") return;
pointerYRef.current = event.clientY;
targetTimeRef.current = mapPointerYToTime(
event.clientY,
window.innerHeight,
);
if (rafIdRef.current === null) {
rafIdRef.current = requestAnimationFrame(animate);
}
}
```
Üretim kodunda ayrıca metadata yüklenmesini, video priming ihtiyacını, sekme görünürlüğünü ve listener temizliğini yönetmek gerekir.
## 7. Component'i kodlatmak için hazır prompt
Aşağıdaki prompt Next.js, React veya benzer bir frontend projesinde coding agent ile kullanılabilir. Köşeli alanları kendi projenize göre doldurun.
```prompt
Mevcut [[FRAMEWORK]] projesine, ekranın sağ altında duran ve farenin yalnızca
Y konumunu takip eden tekrar kullanılabilir `PointerPortraitFollower` component'i
ekle. Stil sistemi: [[STYLING_SYSTEM]].
Asset'ler:
- Video: [[VIDEO_PATH]]
- Poster: [[POSTER_PATH]]
Sistem sabitleri:
- TOTAL_DURATION = 4
- ACTIVE_START = 0.25
- ACTIVE_END = 3.75
- DEFAULT_TIME = 2
- SMOOTHING = 0.12
Davranış:
- Video normal şekilde oynatılmayacak; paused tutulacak.
- Yalnızca pointerY kullan. pointerX zamanlamayı hiçbir şekilde etkilemesin.
- progress = clamp(1 - pointerY / window.innerHeight, 0, 1)
- targetTime = ACTIVE_START + progress * (ACTIVE_END - ACTIVE_START)
- Global pointermove listener kullan fakat pointer event başına React state
güncelleme.
- pointerY, targetTime ve currentTime değerlerini ref içinde tut.
- Tek requestAnimationFrame döngüsünde damping uygula.
- Seek işlemlerini yaklaşık 3060 Hz ile sınırla ve çok küçük farklarda atla.
- Fare pencere dışına çıktığında veya pencere odağı kaybolduğunda 2.00 saniyelik
nötr poza yumuşakça dön.
Video:
- muted, playsInline, preload="auto", controls yok, autoplay yok
- loadedmetadata sonrasında 2.00 saniyeye getir
- İlk gerçek pointer hareketinde gerekiyorsa muted olarak kısa prime et ve pause et
- Asset hatasında kırık video ikonu yerine poster göster
Yerleşim:
- position: fixed; right: [[RIGHT_OFFSET]]; bottom: [[BOTTOM_OFFSET]]
- width: [[DESKTOP_WIDTH]]; aspect-ratio: 1 / 1; z-index: [[Z_INDEX]]
- object-fit: contain; background: [[BACKGROUND_COLOR]]
- pointer-events: none; user-select: none; aria-hidden: true
- Border, radius, shadow veya yatay aynalama ekleme
Responsive:
- pointer: coarse veya dar ekranda animasyonu kapat
- Touch hareketlerini fare gibi yorumlama
- prefers-reduced-motion durumunda poster göster veya component'i gizle
- CTA, link ve menülerin tıklanmasını engelleme
Yaşam döngüsü:
- SSR sırasında window/document kullanma
- visibilitychange ile görünmeyen sekmede RAF ve seek'i durdur
- pointermove, pointerleave, blur, resize, visibilitychange ve RAF temizliğini
unmount sırasında eksiksiz yap
- Aynı anda birden fazla RAF döngüsü başlatma
Mapping ve clamp işlemlerini saf TypeScript yardımcılarına ayır. Projede test
altyapısı varsa alt, orta, üst ve clamp sınırları için test ekle. Ağır animasyon
kütüphanesi ekleme. Build, typecheck, lint ve mevcut testleri çalıştır.
```
Bu uygulamadaki örnek değişkenler:
```text
[[FRAMEWORK]] = Next.js App Router, React, TypeScript
[[STYLING_SYSTEM]] = Tailwind CSS and Poyraz UI
[[VIDEO_PATH]] = /media/cursor-portrait/poyraz-bottom-right.mp4
[[POSTER_PATH]] = /media/cursor-portrait/poyraz-bottom-right-poster.webp
[[RIGHT_OFFSET]] = 24px
[[BOTTOM_OFFSET]] = 0px
[[DESKTOP_WIDTH]] = clamp(110px, 11vw, 170px)
[[Z_INDEX]] = 40
[[BACKGROUND_COLOR]] = #FFFFFF
```
## 8. Mobil, erişilebilirlik ve fallback
Bu efekt masaüstünde fare ile anlam kazanıyor. Touch hareketlerini pointer takibi gibi yorumlamak sayfayı kullanmayı zorlaştırır ve gereksiz video decode maliyeti oluşturur.
Benim tercihlerim:
- `pointer: coarse` cihazlarda component'i render etmemek.
- `840px` altındaki ekranlarda tamamen gizlemek.
- `prefers-reduced-motion` tercihine saygı göstermek.
- Koyu temada beyaz arka planlı asset'i gizlemek.
- Portreyi `pointer-events: none` ve `aria-hidden="true"` ile dekoratif tutmak.
- Video yüklenmezse poster göstermek.
## 9. Kalite kontrol listesi
### Video
- İlk, orta ve son karede aynı kişi görünüyor mu?
- Baş video boyunca yaklaşık 60 derece sola dönük kalıyor mu?
- İlk kare aşağı-sola, orta kare yatay-sola, son kare yukarı-sola mı bakıyor?
- Saç, kulak, çene ve yüz orta karelerde bozuluyor mu?
- Omuzlar veya tişört istemeden hareket ediyor mu?
- Kamera, ışık veya beyaz arka plan titreşiyor mu?
- Video tersine sarıldığında hareket doğal görünüyor mu?
### Web
- Video fare hareket etmeden kendi kendine oynuyor mu? Oynamamalı.
- Fare yukarı ve aşağı giderken doğru yönde sarılıyor mu?
- Yalnızca sağa-sola harekette video zamanı sabit kalıyor mu?
- Hızlı harekette seek kuyruğu veya gecikme oluşuyor mu?
- Fare pencere dışına çıktığında nötr poza dönüyor mu?
- Portre linklerin ve CTA'ların tıklanmasını engelliyor mu?
- Mobilde ve reduced-motion modunda animasyon kapanıyor mu?
- Video yüklenmezse poster görünüyor mu?
- Sayfa değişiminden sonra listener veya RAF ikiye katlanıyor mu?
## Kendi projenize uyarlayın
Bu sistemi farklı bir kişi, çizim veya marka maskotuna taşımak için beş şey yeterli:
1. `[[...]]` değişkenlerini karakterinize göre doldurun.
2. 1:1 ve düz arka planlı tutarlı bir master kare üretin.
3. MiniMax H3 video promptunda yalnızca istediğiniz hareket eksenini tarif edin.
4. Aktif video aralığını farenin aynı eksenine map edin.
5. Optimize video ve poster yollarını component'e bağlayın.
En kritik karar, videoda olmayan bir hareketi kod tarafında taklit etmeye çalışmamaktır. Yapay zeka videosunu kontrollü bir hareket plakası olarak tasarladığınızda efekt hem daha doğal hem de daha kolay test edilebilir hale gelir.
+129
View File
@@ -0,0 +1,129 @@
import "server-only";
import fs from "node:fs/promises";
import path from "node:path";
import matter from "gray-matter";
export type AnimationSourceLocale = "tr" | "en";
export type AnimationSource = {
slug: string;
title: string;
excerpt: string;
coverImage: string;
platform: string;
tools: string[];
date: string;
author: string;
markdown: string;
lang: AnimationSourceLocale;
};
const ANIMATION_SOURCES_DIR = path.join(
process.cwd(),
"content",
"animation-sources",
);
function toSafeString(value: unknown, fallback = "") {
if (typeof value !== "string") return fallback;
const trimmed = value.trim();
return trimmed || fallback;
}
function toStringArray(value: unknown) {
if (Array.isArray(value)) {
return value
.filter((item): item is string => typeof item === "string")
.map((item) => item.trim())
.filter(Boolean);
}
if (typeof value === "string") {
return value
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
return [];
}
function normalizeFileSlug(fileName: string) {
return fileName.replace(/\.(tr|en)\.md$/i, "").replace(/\.md$/i, "");
}
function normalizeLocale(value: unknown): AnimationSourceLocale {
return value === "en" ? "en" : "tr";
}
function mapMarkdownToAnimationSource(
fileName: string,
raw: string,
): AnimationSource {
const parsed = matter(raw);
const fallbackSlug = normalizeFileSlug(fileName);
return {
slug: toSafeString(parsed.data.slug, fallbackSlug),
title: toSafeString(parsed.data.title, fallbackSlug),
excerpt: toSafeString(parsed.data.excerpt),
coverImage: toSafeString(
parsed.data.coverImage,
"/media/cursor-portrait/poyraz-bottom-right-poster.webp",
),
platform: toSafeString(parsed.data.platform, "Web"),
tools: toStringArray(parsed.data.tools),
date: toSafeString(parsed.data.date),
author: toSafeString(parsed.data.author, "Poyraz Avsever"),
markdown: parsed.content.trim(),
lang: normalizeLocale(parsed.data.lang),
};
}
function toTimestamp(value: string) {
const timestamp = Date.parse(value);
return Number.isNaN(timestamp) ? 0 : timestamp;
}
export async function listAnimationSources(
locale?: string,
): Promise<AnimationSource[]> {
let files: string[];
try {
files = await fs.readdir(ANIMATION_SOURCES_DIR);
} catch {
return [];
}
const sources = await Promise.all(
files
.filter((fileName) => fileName.endsWith(".md"))
.map(async (fileName) => {
const raw = await fs.readFile(
path.join(ANIMATION_SOURCES_DIR, fileName),
"utf8",
);
return mapMarkdownToAnimationSource(fileName, raw);
}),
);
return sources
.filter((source) => !locale || source.lang === locale)
.sort((a, b) => {
const dateDifference = toTimestamp(b.date) - toTimestamp(a.date);
return dateDifference || a.title.localeCompare(b.title);
});
}
export async function getAnimationSourceBySlug(
slug: string,
locale: string,
): Promise<AnimationSource | null> {
const safeSlug = slug.trim().toLowerCase();
if (!safeSlug) return null;
const sources = await listAnimationSources(locale);
return sources.find((source) => source.slug.toLowerCase() === safeSlug) ?? null;
}
+1 -45
View File
@@ -70,33 +70,6 @@ export const SOCIAL_LINKS = [
}, },
] as const; ] as const;
export const ANIMATION_RESOURCE_LINKS = [
{
id: "motion",
label: "Motion",
href: "https://motion.dev/",
icon: "mdi:motion-play-outline",
},
{
id: "gsap",
label: "GSAP",
href: "https://gsap.com/resources/",
icon: "mdi:lightning-bolt-outline",
},
{
id: "lottiefiles",
label: "LottieFiles",
href: "https://lottiefiles.com/",
icon: "mdi:movie-open-play-outline",
},
{
id: "rive",
label: "Rive",
href: "https://rive.app/",
icon: "mdi:vector-curve",
},
] as const;
export const NAV_DROPDOWN_GROUPS = [ export const NAV_DROPDOWN_GROUPS = [
{ {
id: "others", id: "others",
@@ -107,7 +80,7 @@ export const NAV_DROPDOWN_GROUPS = [
{ {
id: "animationResources", id: "animationResources",
label: "Animasyon Kaynakları", label: "Animasyon Kaynakları",
href: "/links?category=resources&query=animation", href: "/animation-sources",
icon: "mdi:motion-play-outline", icon: "mdi:motion-play-outline",
external: false, external: false,
keywords: ["animasyon", "animation", "kaynak", "resource", "motion"], keywords: ["animasyon", "animation", "kaynak", "resource", "motion"],
@@ -191,23 +164,6 @@ export const LINK_DIRECTORY: LinkDirectoryItem[] = [
category: "social" as const, category: "social" as const,
keywords: [item.label, item.href, "sosyal", "profile", "platform"], keywords: [item.label, item.href, "sosyal", "profile", "platform"],
})), })),
...ANIMATION_RESOURCE_LINKS.map((item) => ({
id: item.id,
label: item.label,
href: item.href,
icon: item.icon,
external: true,
category: "resources" as const,
keywords: [
item.label,
item.href,
"animasyon",
"animation",
"kaynak",
"resource",
"motion",
],
})),
...TOP_ICON_LINKS.map((item) => ({ ...TOP_ICON_LINKS.map((item) => ({
id: item.id, id: item.id,
label: item.label, label: item.label,
+45
View File
@@ -0,0 +1,45 @@
export type MarkdownHeading = {
id: string;
text: string;
level: 2 | 3;
};
export function cleanMarkdownHeading(text: string) {
return text
.replace(/\[([^\]]+)\]\([^\)]+\)/g, "$1")
.replace(/[*_~`]/g, "")
.trim();
}
export function slugifyMarkdownHeading(text: string) {
return cleanMarkdownHeading(text)
.toLocaleLowerCase("tr-TR")
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/ı/g, "i")
.replace(/[^a-z0-9\s-]/g, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
}
export function parseMarkdownHeadings(markdown: string): MarkdownHeading[] {
const headings: MarkdownHeading[] = [];
for (const line of markdown.split("\n")) {
const match = /^(#{2,3})\s+(.+)$/.exec(line.trim());
if (!match) continue;
const text = cleanMarkdownHeading(match[2]);
const id = slugifyMarkdownHeading(text);
if (!id || !text) continue;
headings.push({
id,
text,
level: match[1].length as 2 | 3,
});
}
return headings;
}
+11
View File
@@ -85,6 +85,17 @@
"diagramLoading": "Diagram rendering...", "diagramLoading": "Diagram rendering...",
"diagramError": "Could not render Mermaid diagram." "diagramError": "Could not render Mermaid diagram."
}, },
"AnimationSources": {
"title": "Animation Sources",
"description": "Production notes, prompts, and reusable code from animations I create for web and mobile products.",
"empty": "No animation sources have been added yet.",
"itemCount": "{count} sources",
"back": "Back to animation sources",
"toc": "Table of Contents",
"closeToc": "Close table of contents",
"copy": "Copy",
"copied": "Copied"
},
"Links": { "Links": {
"desc": "Here you can find all my social accounts, portfolio pages, and quick access links in one place. Open and share directly.", "desc": "Here you can find all my social accounts, portfolio pages, and quick access links in one place. Open and share directly.",
"allLinks": "All Links", "allLinks": "All Links",
+11
View File
@@ -85,6 +85,17 @@
"diagramLoading": "Diyagram hazırlanıyor...", "diagramLoading": "Diyagram hazırlanıyor...",
"diagramError": "Mermaid diyagramı oluşturulamadı." "diagramError": "Mermaid diyagramı oluşturulamadı."
}, },
"AnimationSources": {
"title": "Animasyon Kaynakları",
"description": "Web ve mobil ürünler için hazırladığım animasyonların üretim süreçleri, promptları ve uygulanabilir kodları.",
"empty": "Henüz animasyon kaynağı eklenmedi.",
"itemCount": "{count} kaynak",
"back": "Animasyon kaynaklarına dön",
"toc": "İçindekiler",
"closeToc": "İçindekileri kapat",
"copy": "Kopyala",
"copied": "Kopyalandı"
},
"Links": { "Links": {
"desc": "Burada sosyal hesaplarım, portfolyo sayfalarım ve hızlı erişim linklerimin tamamı tek yerde duruyor. Direkt açıp paylaşabilirsin.", "desc": "Burada sosyal hesaplarım, portfolyo sayfalarım ve hızlı erişim linklerimin tamamı tek yerde duruyor. Direkt açıp paylaşabilirsin.",
"allLinks": "Tüm Linkler", "allLinks": "Tüm Linkler",
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB