feat(animation-sources): add content-driven guide system
This commit is contained in:
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -1,11 +1,15 @@
|
||||
import { listBlogDetails } from "@/data/blog-detail";
|
||||
import { listAnimationSources } from "@/data/animation-sources";
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
const SITE_URL =
|
||||
process.env.NEXT_PUBLIC_SITE_URL || "https://poyrazavsever.com";
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const posts = await listBlogDetails();
|
||||
const [posts, animationSources] = await Promise.all([
|
||||
listBlogDetails(),
|
||||
listAnimationSources(),
|
||||
]);
|
||||
|
||||
const staticRoutes: MetadataRoute.Sitemap = [
|
||||
{
|
||||
@@ -62,6 +66,12 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.4,
|
||||
},
|
||||
{
|
||||
url: `${SITE_URL}/animation-sources`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.7,
|
||||
},
|
||||
];
|
||||
|
||||
const blogRoutes: MetadataRoute.Sitemap = posts.map((post) => ({
|
||||
@@ -71,5 +81,14 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -60,6 +60,15 @@ function getNavLinkClass(isActive: boolean) {
|
||||
].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 = {
|
||||
theme: ThemeMode;
|
||||
onThemeChange: (theme: ThemeMode) => void;
|
||||
@@ -237,13 +246,13 @@ export function SiteNavbar({ theme, onThemeChange }: SiteNavbarProps) {
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={`${getNavLinkClass(
|
||||
className={getDesktopDropdownTriggerClass(
|
||||
group.items.some(
|
||||
(groupItem) =>
|
||||
!groupItem.external &&
|
||||
isActiveLink(groupItem.href.split("?")[0]),
|
||||
),
|
||||
)} h-9 cursor-pointer items-center gap-1 px-2.5`}
|
||||
)}
|
||||
>
|
||||
<span>{t(group.id)}</span>
|
||||
<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.00–0.25`: hold the down-left pose.
|
||||
2. `0.25–3.75`: move from down-left to up-left.
|
||||
3. Around `2.00`: reach the neutral horizontal-left pose.
|
||||
4. `3.75–4.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.00–0.25 seconds: hold a clean down-left gaze and head-tilt pose.
|
||||
- 0.25–3.75 seconds: move smoothly and continuously from down-left to up-left.
|
||||
- At exactly 2.00 seconds: reach a neutral horizontal-left gaze.
|
||||
- 3.75–4.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.00–0.25
|
||||
- Active motion: 0.25–3.75
|
||||
- Final hold: 3.75–4.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 `0–1`, 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 30–60 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.00–0.25`: aşağı-sola bakış pozu sabit tutulur.
|
||||
2. `0.25–3.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.75–4.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.00–0.25 seconds: hold a clean down-left gaze and head-tilt pose.
|
||||
- 0.25–3.75 seconds: move smoothly and continuously from down-left to up-left.
|
||||
- At exactly 2.00 seconds: reach a neutral horizontal-left gaze.
|
||||
- 3.75–4.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.00–0.25
|
||||
- Aktif hareket: 0.25–3.75
|
||||
- Son poz: 3.75–4.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 `0–1` 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 30–60 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.
|
||||
@@ -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
@@ -70,33 +70,6 @@ export const SOCIAL_LINKS = [
|
||||
},
|
||||
] 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 = [
|
||||
{
|
||||
id: "others",
|
||||
@@ -107,7 +80,7 @@ export const NAV_DROPDOWN_GROUPS = [
|
||||
{
|
||||
id: "animationResources",
|
||||
label: "Animasyon Kaynakları",
|
||||
href: "/links?category=resources&query=animation",
|
||||
href: "/animation-sources",
|
||||
icon: "mdi:motion-play-outline",
|
||||
external: false,
|
||||
keywords: ["animasyon", "animation", "kaynak", "resource", "motion"],
|
||||
@@ -191,23 +164,6 @@ export const LINK_DIRECTORY: LinkDirectoryItem[] = [
|
||||
category: "social" as const,
|
||||
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) => ({
|
||||
id: item.id,
|
||||
label: item.label,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -85,6 +85,17 @@
|
||||
"diagramLoading": "Diagram rendering...",
|
||||
"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": {
|
||||
"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",
|
||||
|
||||
@@ -85,6 +85,17 @@
|
||||
"diagramLoading": "Diyagram hazırlanıyor...",
|
||||
"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": {
|
||||
"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",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.8 MiB |
Reference in New Issue
Block a user