diff --git a/Dockerfile b/Dockerfile index 12a3f45..21aba4b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/docker-compose.v2.yml b/docker-compose.v2.yml index 5ac1938..7a83052 100644 --- a/docker-compose.v2.yml +++ b/docker-compose.v2.yml @@ -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 diff --git a/public/template-media/AppShowcase3D-16x9.mp4 b/public/template-media/AppShowcase3D-16x9.mp4 new file mode 100644 index 0000000..7400ade Binary files /dev/null and b/public/template-media/AppShowcase3D-16x9.mp4 differ diff --git a/public/template-media/AppShowcase3D-1x1.mp4 b/public/template-media/AppShowcase3D-1x1.mp4 new file mode 100644 index 0000000..148829e Binary files /dev/null and b/public/template-media/AppShowcase3D-1x1.mp4 differ diff --git a/public/template-media/AppShowcase3D-9x16.mp4 b/public/template-media/AppShowcase3D-9x16.mp4 new file mode 100644 index 0000000..0ab115b Binary files /dev/null and b/public/template-media/AppShowcase3D-9x16.mp4 differ diff --git a/scripts/seed_remotion_templates.py b/scripts/seed_remotion_templates.py index 978364e..8c46a66 100644 --- a/scripts/seed_remotion_templates.py +++ b/scripts/seed_remotion_templates.py @@ -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'' for i, c in enumerate(colors)) return f'{rects}' @@ -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 (" diff --git a/src/app/[locale]/templates/[id]/page.tsx b/src/app/[locale]/templates/[id]/page.tsx index 3872de3..adf74c3 100644 --- a/src/app/[locale]/templates/[id]/page.tsx +++ b/src/app/[locale]/templates/[id]/page.tsx @@ -27,7 +27,7 @@ async function resolveTemplate(id: string): Promise 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; diff --git a/src/components/templates/TemplateDetailPreview.tsx b/src/components/templates/TemplateDetailPreview.tsx index 6c04335..da8f9d3 100644 --- a/src/components/templates/TemplateDetailPreview.tsx +++ b/src/components/templates/TemplateDetailPreview.tsx @@ -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 (
diff --git a/src/lib/admin-api.ts b/src/lib/admin-api.ts index 1c0749b..cee3daf 100644 --- a/src/lib/admin-api.ts +++ b/src/lib/admin-api.ts @@ -214,13 +214,18 @@ export async function fetchProject(slug: string): Promise { * studio copies. Returns [] when none / unreachable. */ export async function fetchTemplateVariants( slug: string -): Promise> { +): Promise> { 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. */ diff --git a/src/lib/video-templates-catalog.ts b/src/lib/video-templates-catalog.ts index 0be0cf2..748fd8e 100644 --- a/src/lib/video-templates-catalog.ts +++ b/src/lib/video-templates-catalog.ts @@ -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;