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 ────────────────────────────────────────────
|
||||
FROM mirror.soroushasadi.com/node:20-alpine AS deps
|
||||
# NOTE: do NOT `apk add libc6-compat` here — the deps stage only runs `npm ci`
|
||||
# (which doesn't need it) and the build/runtime stages omit it anyway. Pulling it
|
||||
# reaches Alpine's public CDN (dl-cdn.alpinelinux.org), which is unreachable from
|
||||
# the CI server (only the Nexus mirror is) and fails the whole build.
|
||||
FROM mirror.soroushasadi.com/node:20-slim AS deps
|
||||
# Debian (glibc) base on purpose: Alpine (musl) needs `libc6-compat` for next-swc,
|
||||
# which is only on the geo-blocked Alpine CDN (unreachable from the CI server).
|
||||
# Debian ships glibc, so next-swc's gnu binary loads natively — no apk, no CDN.
|
||||
WORKDIR /app
|
||||
|
||||
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
|
||||
|
||||
# ── Stage 2: build ───────────────────────────────────────────────────────────
|
||||
FROM mirror.soroushasadi.com/node:20-alpine AS builder
|
||||
FROM mirror.soroushasadi.com/node:20-slim AS builder
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
@@ -54,15 +53,15 @@ ENV NODE_ENV=production
|
||||
RUN npm run build
|
||||
|
||||
# ── Stage 3: production runner ────────────────────────────────────────────────
|
||||
FROM mirror.soroushasadi.com/node:20-alpine AS runner
|
||||
FROM mirror.soroushasadi.com/node:20-slim AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# Create a non-root user (security best practice)
|
||||
RUN addgroup --system --gid 1001 nodejs \
|
||||
&& adduser --system --uid 1001 nextjs
|
||||
# Create a non-root user (security best practice). Debian uses groupadd/useradd.
|
||||
RUN groupadd --system --gid 1001 nodejs \
|
||||
&& useradd --system --uid 1001 --gid nodejs nextjs
|
||||
|
||||
# Copy public assets
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
@@ -370,7 +370,7 @@ services:
|
||||
gateway:
|
||||
condition: service_healthy
|
||||
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
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -67,6 +67,10 @@ TEXTCOLORS = {
|
||||
"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):
|
||||
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>'
|
||||
@@ -94,10 +98,11 @@ for idx, (tid, slug, name, desc, dur, texts, (accent, sec, bg)) in enumerate(T):
|
||||
pid = uid(f"p-{tid}-{asp}")
|
||||
sid = uid(f"s-{tid}-{asp}")
|
||||
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(
|
||||
"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 ("
|
||||
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);")
|
||||
out.append(
|
||||
"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 variants = (await fetchTemplateVariants(id))
|
||||
.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 VIDEO_TEMPLATES_CATALOG.find((item) => item.id === id) ?? null;
|
||||
|
||||
@@ -32,8 +32,10 @@ export function TemplateDetailPreview({
|
||||
onSelectAspect,
|
||||
}: TemplateDetailPreviewProps) {
|
||||
const aspectOptions = getTemplateDetailAspectRatios(template);
|
||||
const posterSrc = template.coverImageUrl ?? getVideoTemplateImageSrc(template.id);
|
||||
const videoSrc = template.previewVideoUrl ?? getTemplatePreviewVideoSrc(template.id);
|
||||
// Use the render that matches the selected aspect (not the 16:9 cover cropped).
|
||||
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 (
|
||||
<div>
|
||||
|
||||
@@ -214,13 +214,18 @@ export async function fetchProject(slug: string): Promise<AdminProject | null> {
|
||||
* studio copies. Returns [] when none / unreachable. */
|
||||
export async function fetchTemplateVariants(
|
||||
slug: string
|
||||
): Promise<Array<{ aspect: string; projectId: string }>> {
|
||||
): Promise<Array<{ aspect: string; projectId: string; image?: string; previewVideo?: string }>> {
|
||||
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)}`);
|
||||
return (c?.projects ?? [])
|
||||
.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. */
|
||||
|
||||
@@ -63,6 +63,10 @@ export type TemplateDetailAspectRatio = "16:9" | "1:1" | "9:16";
|
||||
export interface TemplateVariant {
|
||||
aspect: TemplateDetailAspectRatio;
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user