feat(templates): aspect-ratio picker drives which variant is built
The detail page now loads a template's real published aspect variants (16:9/1:1/9:16) from the content container and the preview chips select among them. Build now copies the SELECTED variant's scene graph (passes that variant's content project UUID), not a default. Selection is lifted to TemplateDetailContent and shared by the preview picker and the build button; the preview box reflects the chosen aspect. Verified on insta-promo (16:9 + a duplicated 1:1 variant): both chips render, and building 1:1 copies the 1:1 project's scenes (1 scene, 6 fields). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2,13 +2,16 @@ import type { Metadata } from "next";
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
import { TemplateDetailContent } from "@/components/templates/TemplateDetailContent";
|
import { TemplateDetailContent } from "@/components/templates/TemplateDetailContent";
|
||||||
import { fetchProject } from "@/lib/admin-api";
|
import { fetchProject, fetchTemplateVariants } from "@/lib/admin-api";
|
||||||
import {
|
import {
|
||||||
adminProjectToCatalogTemplate,
|
adminProjectToCatalogTemplate,
|
||||||
VIDEO_TEMPLATES_CATALOG,
|
VIDEO_TEMPLATES_CATALOG,
|
||||||
|
type TemplateDetailAspectRatio,
|
||||||
type VideoCatalogTemplate,
|
type VideoCatalogTemplate,
|
||||||
} from "@/lib/video-templates-catalog";
|
} from "@/lib/video-templates-catalog";
|
||||||
|
|
||||||
|
const SUPPORTED_ASPECTS = new Set<TemplateDetailAspectRatio>(["16:9", "1:1", "9:16"]);
|
||||||
|
|
||||||
interface TemplateDetailPageProps {
|
interface TemplateDetailPageProps {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
}
|
}
|
||||||
@@ -20,7 +23,13 @@ interface TemplateDetailPageProps {
|
|||||||
*/
|
*/
|
||||||
async function resolveTemplate(id: string): Promise<VideoCatalogTemplate | null> {
|
async function resolveTemplate(id: string): Promise<VideoCatalogTemplate | null> {
|
||||||
const admin = await fetchProject(id);
|
const admin = await fetchProject(id);
|
||||||
if (admin) return adminProjectToCatalogTemplate(admin);
|
if (admin) {
|
||||||
|
const base = adminProjectToCatalogTemplate(admin);
|
||||||
|
const variants = (await fetchTemplateVariants(id))
|
||||||
|
.filter((v) => SUPPORTED_ASPECTS.has(v.aspect as TemplateDetailAspectRatio))
|
||||||
|
.map((v) => ({ aspect: v.aspect as TemplateDetailAspectRatio, projectId: v.projectId }));
|
||||||
|
return { ...base, variants };
|
||||||
|
}
|
||||||
return VIDEO_TEMPLATES_CATALOG.find((item) => item.id === id) ?? null;
|
return VIDEO_TEMPLATES_CATALOG.find((item) => item.id === id) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,39 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
import { TemplateDetailBreadcrumb } from "@/components/templates/TemplateDetailBreadcrumb";
|
import { TemplateDetailBreadcrumb } from "@/components/templates/TemplateDetailBreadcrumb";
|
||||||
import { TemplateDetailExamples } from "@/components/templates/TemplateDetailExamples";
|
import { TemplateDetailExamples } from "@/components/templates/TemplateDetailExamples";
|
||||||
import { TemplateDetailInfo } from "@/components/templates/TemplateDetailInfo";
|
import { TemplateDetailInfo } from "@/components/templates/TemplateDetailInfo";
|
||||||
import { TemplateDetailPreview } from "@/components/templates/TemplateDetailPreview";
|
import { TemplateDetailPreview } from "@/components/templates/TemplateDetailPreview";
|
||||||
import type { VideoCatalogTemplate } from "@/lib/video-templates-catalog";
|
import {
|
||||||
|
getTemplateDetailAspectRatios,
|
||||||
|
type TemplateDetailAspectRatio,
|
||||||
|
type VideoCatalogTemplate,
|
||||||
|
} from "@/lib/video-templates-catalog";
|
||||||
|
|
||||||
interface TemplateDetailContentProps {
|
interface TemplateDetailContentProps {
|
||||||
template: VideoCatalogTemplate;
|
template: VideoCatalogTemplate;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TemplateDetailContent({ template }: TemplateDetailContentProps) {
|
export function TemplateDetailContent({ template }: TemplateDetailContentProps) {
|
||||||
|
// Selected aspect is shared so the preview picker drives which variant gets built.
|
||||||
|
const aspects = getTemplateDetailAspectRatios(template);
|
||||||
|
const [selectedAspect, setSelectedAspect] = useState<TemplateDetailAspectRatio>(
|
||||||
|
aspects[0] ?? "16:9"
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-7xl px-4 py-8 lg:px-8 lg:py-12">
|
<div className="mx-auto max-w-7xl px-4 py-8 lg:px-8 lg:py-12">
|
||||||
<TemplateDetailBreadcrumb templateName={template.name} />
|
<TemplateDetailBreadcrumb templateName={template.name} />
|
||||||
|
|
||||||
<div className="mt-6 grid gap-10 lg:grid-cols-[3fr_2fr] lg:items-start">
|
<div className="mt-6 grid gap-10 lg:grid-cols-[3fr_2fr] lg:items-start">
|
||||||
<TemplateDetailPreview template={template} />
|
<TemplateDetailPreview
|
||||||
<TemplateDetailInfo template={template} />
|
template={template}
|
||||||
|
selectedAspect={selectedAspect}
|
||||||
|
onSelectAspect={setSelectedAspect}
|
||||||
|
/>
|
||||||
|
<TemplateDetailInfo template={template} selectedAspect={selectedAspect} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TemplateDetailExamples templateId={template.id} />
|
<TemplateDetailExamples templateId={template.id} />
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
getVideoTemplateStyleImageSrc,
|
getVideoTemplateStyleImageSrc,
|
||||||
TEMPLATE_STYLE_COUNT,
|
TEMPLATE_STYLE_COUNT,
|
||||||
toProjectTemplate,
|
toProjectTemplate,
|
||||||
|
type TemplateDetailAspectRatio,
|
||||||
type VideoCatalogTemplate,
|
type VideoCatalogTemplate,
|
||||||
} from "@/lib/video-templates-catalog";
|
} from "@/lib/video-templates-catalog";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -29,9 +30,10 @@ const STYLE_LABEL_KEYS = [
|
|||||||
|
|
||||||
interface TemplateDetailInfoProps {
|
interface TemplateDetailInfoProps {
|
||||||
template: VideoCatalogTemplate;
|
template: VideoCatalogTemplate;
|
||||||
|
selectedAspect: TemplateDetailAspectRatio;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TemplateDetailInfo({ template }: TemplateDetailInfoProps) {
|
export function TemplateDetailInfo({ template, selectedAspect }: TemplateDetailInfoProps) {
|
||||||
const t = useTranslations("auto.componentsTemplatesTemplateDetailInfo");
|
const t = useTranslations("auto.componentsTemplatesTemplateDetailInfo");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [selectedStyle, setSelectedStyle] = useState(0);
|
const [selectedStyle, setSelectedStyle] = useState(0);
|
||||||
@@ -44,7 +46,14 @@ export function TemplateDetailInfo({ template }: TemplateDetailInfoProps) {
|
|||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
setIsCreating(true);
|
setIsCreating(true);
|
||||||
const result = await createProjectFromTemplate(toProjectTemplate(template));
|
// Build the variant matching the selected aspect (its content project id); fall
|
||||||
|
// back to the template id (slug) which the API resolves to a default variant.
|
||||||
|
const variant = template.variants?.find((v) => v.aspect === selectedAspect);
|
||||||
|
const base = toProjectTemplate(template);
|
||||||
|
const result = await createProjectFromTemplate({
|
||||||
|
...base,
|
||||||
|
id: variant?.projectId ?? base.id,
|
||||||
|
});
|
||||||
|
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
@@ -73,6 +82,8 @@ export function TemplateDetailInfo({ template }: TemplateDetailInfoProps) {
|
|||||||
<span>{categoryLabel}</span>
|
<span>{categoryLabel}</span>
|
||||||
<StatDot />
|
<StatDot />
|
||||||
<span>{durationLabel}</span>
|
<span>{durationLabel}</span>
|
||||||
|
<StatDot />
|
||||||
|
<span>{selectedAspect}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TemplateDetailRating />
|
<TemplateDetailRating />
|
||||||
|
|||||||
@@ -17,19 +17,35 @@ import { cn } from "@/lib/utils";
|
|||||||
|
|
||||||
interface TemplateDetailPreviewProps {
|
interface TemplateDetailPreviewProps {
|
||||||
template: VideoCatalogTemplate;
|
template: VideoCatalogTemplate;
|
||||||
|
selectedAspect: TemplateDetailAspectRatio;
|
||||||
|
onSelectAspect: (aspect: TemplateDetailAspectRatio) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TemplateDetailPreview({ template }: TemplateDetailPreviewProps) {
|
const ASPECT_BOX: Record<TemplateDetailAspectRatio, string> = {
|
||||||
|
"16:9": "aspect-video",
|
||||||
|
"1:1": "aspect-square mx-auto max-w-md",
|
||||||
|
"9:16": "aspect-[9/16] mx-auto max-w-[300px]",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TemplateDetailPreview({
|
||||||
|
template,
|
||||||
|
selectedAspect,
|
||||||
|
onSelectAspect,
|
||||||
|
}: TemplateDetailPreviewProps) {
|
||||||
const t = useTranslations("auto.componentsTemplatesTemplateDetailPreview");
|
const t = useTranslations("auto.componentsTemplatesTemplateDetailPreview");
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
const [selectedRatio, setSelectedRatio] = useState<TemplateDetailAspectRatio>("16:9");
|
|
||||||
const aspectOptions = getTemplateDetailAspectRatios(template);
|
const aspectOptions = getTemplateDetailAspectRatios(template);
|
||||||
const posterSrc = getVideoTemplateImageSrc(template.id);
|
const posterSrc = getVideoTemplateImageSrc(template.id);
|
||||||
const videoSrc = getTemplatePreviewVideoSrc(template.id);
|
const videoSrc = getTemplatePreviewVideoSrc(template.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="relative aspect-video overflow-hidden rounded-2xl bg-gray-100 shadow-lg">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative overflow-hidden rounded-2xl bg-gray-100 shadow-lg",
|
||||||
|
ASPECT_BOX[selectedAspect]
|
||||||
|
)}
|
||||||
|
>
|
||||||
{isPlaying ? (
|
{isPlaying ? (
|
||||||
<video
|
<video
|
||||||
src={videoSrc}
|
src={videoSrc}
|
||||||
@@ -60,15 +76,15 @@ export function TemplateDetailPreview({ template }: TemplateDetailPreviewProps)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{aspectOptions.length > 1 ? (
|
{aspectOptions.length > 1 ? (
|
||||||
<div className="mt-4 flex gap-2">
|
<div className="mt-4 flex justify-center gap-2 lg:justify-start">
|
||||||
{aspectOptions.map((ratio) => (
|
{aspectOptions.map((ratio) => (
|
||||||
<button
|
<button
|
||||||
key={ratio}
|
key={ratio}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSelectedRatio(ratio)}
|
onClick={() => onSelectAspect(ratio)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-lg border px-4 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2",
|
"rounded-lg border px-4 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2",
|
||||||
selectedRatio === ratio
|
selectedAspect === ratio
|
||||||
? "border-blue-600 text-blue-600"
|
? "border-blue-600 text-blue-600"
|
||||||
: "border-gray-200 text-gray-700 hover:border-gray-300"
|
: "border-gray-200 text-gray-700 hover:border-gray-300"
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -209,6 +209,20 @@ export async function fetchProject(slug: string): Promise<AdminProject | null> {
|
|||||||
return c ? containerToAdminProject(c) : null;
|
return c ? containerToAdminProject(c) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Published aspect-ratio variants of a template container (aspect + content
|
||||||
|
* project id). Used to drive the detail page's aspect picker + which variant the
|
||||||
|
* studio copies. Returns [] when none / unreachable. */
|
||||||
|
export async function fetchTemplateVariants(
|
||||||
|
slug: string
|
||||||
|
): Promise<Array<{ aspect: string; projectId: string }>> {
|
||||||
|
const c = await safeGet<{
|
||||||
|
projects?: Array<{ id?: string; aspect?: string; is_published?: boolean }>;
|
||||||
|
}>(`/v1/templates/${encodeURIComponent(slug)}`);
|
||||||
|
return (c?.projects ?? [])
|
||||||
|
.filter((p) => p?.id && p?.is_published && p?.aspect)
|
||||||
|
.map((p) => ({ aspect: p.aspect as string, projectId: p.id as string }));
|
||||||
|
}
|
||||||
|
|
||||||
/** True when the gateway content endpoint is reachable. */
|
/** True when the gateway content endpoint is reachable. */
|
||||||
export async function isAdminApiAvailable(): Promise<boolean> {
|
export async function isAdminApiAvailable(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -56,7 +56,14 @@ export const ASPECT_RATIO_OPTIONS: {
|
|||||||
{ id: "fourFive", label: "4:5" },
|
{ id: "fourFive", label: "4:5" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export type TemplateDetailAspectRatio = "16:9" | "9:16";
|
export type TemplateDetailAspectRatio = "16:9" | "1:1" | "9:16";
|
||||||
|
|
||||||
|
/** A concrete aspect-ratio variant of a template — maps an aspect to the content
|
||||||
|
* project (UUID) the studio copies its scenes from. */
|
||||||
|
export interface TemplateVariant {
|
||||||
|
aspect: TemplateDetailAspectRatio;
|
||||||
|
projectId: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const TEMPLATE_STYLE_COUNT = 4;
|
export const TEMPLATE_STYLE_COUNT = 4;
|
||||||
|
|
||||||
@@ -66,6 +73,9 @@ export interface VideoCatalogTemplate {
|
|||||||
videoCategory: Exclude<VideoSidebarCategoryId, "all">;
|
videoCategory: Exclude<VideoSidebarCategoryId, "all">;
|
||||||
aspectRatio: Exclude<AspectRatioFilter, "all">;
|
aspectRatio: Exclude<AspectRatioFilter, "all">;
|
||||||
aspectRatios?: readonly TemplateDetailAspectRatio[];
|
aspectRatios?: readonly TemplateDetailAspectRatio[];
|
||||||
|
/** Real aspect variants (with the content project id to copy). Empty for demo
|
||||||
|
* templates. When present, drives the aspect picker + which variant is built. */
|
||||||
|
variants?: TemplateVariant[];
|
||||||
durationType: "flexible" | "fixed";
|
durationType: "flexible" | "fixed";
|
||||||
premium: boolean;
|
premium: boolean;
|
||||||
sceneCount: number;
|
sceneCount: number;
|
||||||
@@ -86,6 +96,10 @@ export function getVideoTemplateCategoryLabel(
|
|||||||
export function getTemplateDetailAspectRatios(
|
export function getTemplateDetailAspectRatios(
|
||||||
template: VideoCatalogTemplate
|
template: VideoCatalogTemplate
|
||||||
): TemplateDetailAspectRatio[] {
|
): TemplateDetailAspectRatio[] {
|
||||||
|
// Real variants take precedence so the picker reflects what can actually be built.
|
||||||
|
if (template.variants && template.variants.length > 0) {
|
||||||
|
return template.variants.map((v) => v.aspect);
|
||||||
|
}
|
||||||
if (template.aspectRatios && template.aspectRatios.length > 0) {
|
if (template.aspectRatios && template.aspectRatios.length > 0) {
|
||||||
return [...template.aspectRatios];
|
return [...template.aspectRatios];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user