fix(detail+docker): per-aspect template preview + Debian frontend base
CI/CD / CI · Web (tsc) (push) Successful in 1m17s
CI/CD / Deploy · full stack (push) Failing after 15s

- 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:
soroush.asadi
2026-06-21 23:04:04 +03:30
parent 60759f35b4
commit 863b9503b3
10 changed files with 33 additions and 18 deletions
+9 -10
View File
@@ -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
+1 -1
View File
@@ -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.
+6 -1
View File
@@ -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 ("
+1 -1
View File
@@ -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>
+8 -3
View File
@@ -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. */
+4
View File
@@ -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;