fix(detail+docker): per-aspect template preview + Debian frontend base
- Template detail page now shows the render matching the SELECTED aspect (poster +
preview video) instead of the 16:9 cover cropped into a 9:16/1:1 box. TemplateVariant
carries per-aspect image/previewVideo; fetchTemplateVariants + the detail page wire them.
- AppShowcase3D ships a distinct preview video per aspect (seed PERASPECT_VIDEO).
- Frontend Dockerfile: Alpine -> node:20-slim (glibc). Fixes next-swc ("ld-linux..."
load failure that broke `next build` once libc6-compat was removed) AND the original
CI Alpine-CDN issue. Healthcheck switched to node (slim has no wget).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+9
-10
@@ -1,9 +1,8 @@
|
|||||||
# ── Stage 1: install dependencies ────────────────────────────────────────────
|
# ── Stage 1: install dependencies ────────────────────────────────────────────
|
||||||
FROM mirror.soroushasadi.com/node:20-alpine AS deps
|
FROM mirror.soroushasadi.com/node:20-slim AS deps
|
||||||
# NOTE: do NOT `apk add libc6-compat` here — the deps stage only runs `npm ci`
|
# Debian (glibc) base on purpose: Alpine (musl) needs `libc6-compat` for next-swc,
|
||||||
# (which doesn't need it) and the build/runtime stages omit it anyway. Pulling it
|
# which is only on the geo-blocked Alpine CDN (unreachable from the CI server).
|
||||||
# reaches Alpine's public CDN (dl-cdn.alpinelinux.org), which is unreachable from
|
# Debian ships glibc, so next-swc's gnu binary loads natively — no apk, no CDN.
|
||||||
# the CI server (only the Nexus mirror is) and fails the whole build.
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json package-lock.json* ./
|
COPY package.json package-lock.json* ./
|
||||||
@@ -21,7 +20,7 @@ RUN for i in 1 2 3 4 5; do \
|
|||||||
echo "npm ci failed after 5 attempts" && exit 1
|
echo "npm ci failed after 5 attempts" && exit 1
|
||||||
|
|
||||||
# ── Stage 2: build ───────────────────────────────────────────────────────────
|
# ── Stage 2: build ───────────────────────────────────────────────────────────
|
||||||
FROM mirror.soroushasadi.com/node:20-alpine AS builder
|
FROM mirror.soroushasadi.com/node:20-slim AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
@@ -54,15 +53,15 @@ ENV NODE_ENV=production
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# ── Stage 3: production runner ────────────────────────────────────────────────
|
# ── Stage 3: production runner ────────────────────────────────────────────────
|
||||||
FROM mirror.soroushasadi.com/node:20-alpine AS runner
|
FROM mirror.soroushasadi.com/node:20-slim AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
# Create a non-root user (security best practice)
|
# Create a non-root user (security best practice). Debian uses groupadd/useradd.
|
||||||
RUN addgroup --system --gid 1001 nodejs \
|
RUN groupadd --system --gid 1001 nodejs \
|
||||||
&& adduser --system --uid 1001 nextjs
|
&& useradd --system --uid 1001 --gid nodejs nextjs
|
||||||
|
|
||||||
# Copy public assets
|
# Copy public assets
|
||||||
COPY --from=builder /app/public ./public
|
COPY --from=builder /app/public ./public
|
||||||
|
|||||||
@@ -370,7 +370,7 @@ services:
|
|||||||
gateway:
|
gateway:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3000 || exit 1"]
|
test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:3000',r=>process.exit(r.statusCode<500?0:1)).on('error',()=>process.exit(1))"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -67,6 +67,10 @@ TEXTCOLORS = {
|
|||||||
"AppShowcase3D": "#0f172a",
|
"AppShowcase3D": "#0f172a",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Templates that ship a distinct preview video PER aspect (so the detail page shows
|
||||||
|
# the matching render, not the 16:9 cropped). Others reuse the single 16:9 preview.
|
||||||
|
PERASPECT_VIDEO = {"AppShowcase3D"}
|
||||||
|
|
||||||
def swatch_svg(colors):
|
def swatch_svg(colors):
|
||||||
rects = "".join(f'<rect x="{i*50}" y="0" width="50" height="40" fill="{c}"/>' for i, c in enumerate(colors))
|
rects = "".join(f'<rect x="{i*50}" y="0" width="50" height="40" fill="{c}"/>' for i, c in enumerate(colors))
|
||||||
return f'<svg xmlns="http://www.w3.org/2000/svg" width="200" height="40">{rects}</svg>'
|
return f'<svg xmlns="http://www.w3.org/2000/svg" width="200" height="40">{rects}</svg>'
|
||||||
@@ -94,10 +98,11 @@ for idx, (tid, slug, name, desc, dur, texts, (accent, sec, bg)) in enumerate(T):
|
|||||||
pid = uid(f"p-{tid}-{asp}")
|
pid = uid(f"p-{tid}-{asp}")
|
||||||
sid = uid(f"s-{tid}-{asp}")
|
sid = uid(f"s-{tid}-{asp}")
|
||||||
thumb = f"{MINIO}/template-media/{tid}-{asp}.png"
|
thumb = f"{MINIO}/template-media/{tid}-{asp}.png"
|
||||||
|
pvideo = f"{MINIO}/template-media/{tid}-{asp}.mp4" if tid in PERASPECT_VIDEO else preview
|
||||||
out.append(
|
out.append(
|
||||||
"INSERT INTO content.projects (id,container_id,name,image,full_demo,original_width,original_height,aspect,"
|
"INSERT INTO content.projects (id,container_id,name,image,full_demo,original_width,original_height,aspect,"
|
||||||
"project_duration_sec,free_fps,choose_mode,resolution,render_engine,render_remotion_comp,is_published,sort) VALUES ("
|
"project_duration_sec,free_fps,choose_mode,resolution,render_engine,render_remotion_comp,is_published,sort) VALUES ("
|
||||||
f"{q(pid)},{q(cid)},{q(aspstr)},{q(thumb)},{q(preview)},{w},{h},{q(aspstr)},"
|
f"{q(pid)},{q(cid)},{q(aspstr)},{q(thumb)},{q(pvideo)},{w},{h},{q(aspstr)},"
|
||||||
f"{dur},30,'FLEXIBLE','FullHD','Remotion',{q(tid+'-'+asp)},TRUE,0);")
|
f"{dur},30,'FLEXIBLE','FullHD','Remotion',{q(tid+'-'+asp)},TRUE,0);")
|
||||||
out.append(
|
out.append(
|
||||||
"INSERT INTO content.scenes (id,project_id,key,title,scene_color_svg,default_duration_sec,sort) VALUES ("
|
"INSERT INTO content.scenes (id,project_id,key,title,scene_color_svg,default_duration_sec,sort) VALUES ("
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ async function resolveTemplate(id: string): Promise<VideoCatalogTemplate | null>
|
|||||||
const base = adminProjectToCatalogTemplate(admin);
|
const base = adminProjectToCatalogTemplate(admin);
|
||||||
const variants = (await fetchTemplateVariants(id))
|
const variants = (await fetchTemplateVariants(id))
|
||||||
.filter((v) => SUPPORTED_ASPECTS.has(v.aspect as TemplateDetailAspectRatio))
|
.filter((v) => SUPPORTED_ASPECTS.has(v.aspect as TemplateDetailAspectRatio))
|
||||||
.map((v) => ({ aspect: v.aspect as TemplateDetailAspectRatio, projectId: v.projectId }));
|
.map((v) => ({ aspect: v.aspect as TemplateDetailAspectRatio, projectId: v.projectId, image: v.image, previewVideo: v.previewVideo }));
|
||||||
return { ...base, variants };
|
return { ...base, variants };
|
||||||
}
|
}
|
||||||
return VIDEO_TEMPLATES_CATALOG.find((item) => item.id === id) ?? null;
|
return VIDEO_TEMPLATES_CATALOG.find((item) => item.id === id) ?? null;
|
||||||
|
|||||||
@@ -32,8 +32,10 @@ export function TemplateDetailPreview({
|
|||||||
onSelectAspect,
|
onSelectAspect,
|
||||||
}: TemplateDetailPreviewProps) {
|
}: TemplateDetailPreviewProps) {
|
||||||
const aspectOptions = getTemplateDetailAspectRatios(template);
|
const aspectOptions = getTemplateDetailAspectRatios(template);
|
||||||
const posterSrc = template.coverImageUrl ?? getVideoTemplateImageSrc(template.id);
|
// Use the render that matches the selected aspect (not the 16:9 cover cropped).
|
||||||
const videoSrc = template.previewVideoUrl ?? getTemplatePreviewVideoSrc(template.id);
|
const variant = template.variants?.find((v) => v.aspect === selectedAspect);
|
||||||
|
const posterSrc = variant?.image ?? template.coverImageUrl ?? getVideoTemplateImageSrc(template.id);
|
||||||
|
const videoSrc = variant?.previewVideo ?? template.previewVideoUrl ?? getTemplatePreviewVideoSrc(template.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -214,13 +214,18 @@ export async function fetchProject(slug: string): Promise<AdminProject | null> {
|
|||||||
* studio copies. Returns [] when none / unreachable. */
|
* studio copies. Returns [] when none / unreachable. */
|
||||||
export async function fetchTemplateVariants(
|
export async function fetchTemplateVariants(
|
||||||
slug: string
|
slug: string
|
||||||
): Promise<Array<{ aspect: string; projectId: string }>> {
|
): Promise<Array<{ aspect: string; projectId: string; image?: string; previewVideo?: string }>> {
|
||||||
const c = await safeGet<{
|
const c = await safeGet<{
|
||||||
projects?: Array<{ id?: string; aspect?: string; is_published?: boolean }>;
|
projects?: Array<{ id?: string; aspect?: string; is_published?: boolean; image?: string | null; full_demo?: string | null; demo?: string | null }>;
|
||||||
}>(`/v1/templates/${encodeURIComponent(slug)}`);
|
}>(`/v1/templates/${encodeURIComponent(slug)}`);
|
||||||
return (c?.projects ?? [])
|
return (c?.projects ?? [])
|
||||||
.filter((p) => p?.id && p?.is_published && p?.aspect)
|
.filter((p) => p?.id && p?.is_published && p?.aspect)
|
||||||
.map((p) => ({ aspect: p.aspect as string, projectId: p.id as string }));
|
.map((p) => ({
|
||||||
|
aspect: p.aspect as string,
|
||||||
|
projectId: p.id as string,
|
||||||
|
image: p.image ?? undefined,
|
||||||
|
previewVideo: p.full_demo ?? p.demo ?? undefined,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True when the gateway content endpoint is reachable. */
|
/** True when the gateway content endpoint is reachable. */
|
||||||
|
|||||||
@@ -63,6 +63,10 @@ export type TemplateDetailAspectRatio = "16:9" | "1:1" | "9:16";
|
|||||||
export interface TemplateVariant {
|
export interface TemplateVariant {
|
||||||
aspect: TemplateDetailAspectRatio;
|
aspect: TemplateDetailAspectRatio;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
/** Per-aspect thumbnail + preview video so the detail page shows the render
|
||||||
|
* that actually matches the selected aspect (not the 16:9 cover cropped). */
|
||||||
|
image?: string;
|
||||||
|
previewVideo?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TEMPLATE_STYLE_COUNT = 4;
|
export const TEMPLATE_STYLE_COUNT = 4;
|
||||||
|
|||||||
Reference in New Issue
Block a user