diff --git a/studio/src/components/FilmStrip/index.tsx b/studio/src/components/FilmStrip/index.tsx
index 5b18652e..32c773d6 100644
--- a/studio/src/components/FilmStrip/index.tsx
+++ b/studio/src/components/FilmStrip/index.tsx
@@ -1,8 +1,119 @@
import { ReactElement } from 'react';
-import styled from 'styled-components';
+import styled, { keyframes } from 'styled-components';
import { theme } from '../../theme';
import type { GeneratedImage } from '../../types';
+// ─── Skeleton / in-progress slots ────────────────────────────────────────────
+
+const shimmer = keyframes`
+ 0% { background-position: -200% center; }
+ 100% { background-position: 200% center; }
+`;
+
+const SkeletonThumb = styled.div`
+ position: relative;
+ flex-shrink: 0;
+ height: 140px;
+ width: 96px;
+ border-right: 1px solid ${theme.colors.border};
+ background: linear-gradient(
+ 90deg,
+ ${theme.colors.bgCard} 25%,
+ ${theme.colors.bgActive} 50%,
+ ${theme.colors.bgCard} 75%
+ );
+ background-size: 200% 100%;
+ animation: ${shimmer} 1.6s ease-in-out infinite;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+`;
+
+const PieWrap = styled.div`
+ position: relative;
+ width: 48px;
+ height: 48px;
+ opacity: 0.9;
+`;
+
+const PieLabel = styled.div`
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 10px;
+ font-weight: 700;
+ color: white;
+ text-shadow: 0 1px 3px rgba(0,0,0,0.8);
+ pointer-events: none;
+`;
+
+function PieSVG({ attempt, total }: { attempt: number | null; total: number }): ReactElement {
+ const r = 20;
+ const cx = 24;
+ const cy = 24;
+
+ if (attempt === null) {
+ // Spinning ring — not yet started
+ return (
+
+ );
+ }
+
+ const fraction = attempt / total;
+ const angle = fraction * 2 * Math.PI;
+ const x = cx + r * Math.sin(angle);
+ const y = cy - r * Math.cos(angle);
+ const large = fraction > 0.5 ? 1 : 0;
+
+ return (
+
+ );
+}
+
+function ActiveSkeleton({ attempt, total }: { attempt: number | null; total: number }): ReactElement {
+ return (
+
+
+
+ {attempt !== null && (
+ {attempt}/{total}
+ )}
+
+
+ );
+}
+
+function QueuedSkeleton(): ReactElement {
+ return ;
+}
+
+// ─── Completed image thumb ────────────────────────────────────────────────────
+
const Thumb = styled.div<{ $favorited: boolean }>`
position: relative;
flex-shrink: 0;
@@ -102,6 +213,22 @@ const QualityDot = styled.div<{ $score: number }>`
box-shadow: 0 0 0 1px rgba(0,0,0,0.4);
`;
+const ViolationBadge = styled.div`
+ position: absolute;
+ top: 4px;
+ left: 6px;
+ background: ${theme.colors.error};
+ color: white;
+ font-size: 9px;
+ font-weight: 700;
+ padding: 1px 4px;
+ border-radius: 3px;
+ letter-spacing: 0.04em;
+ pointer-events: none;
+ text-shadow: none;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.5);
+`;
+
const Empty = styled.div`
flex: 1;
display: flex;
@@ -121,10 +248,14 @@ interface FilmStripProps {
images: GeneratedImage[];
onOpenLightbox: (index: number) => void;
onToggleFavorite: (id: string, favorited: boolean) => void;
+ activeGeneration: { attempt: number | null; totalAttempts: number } | null;
+ queuedCount: number;
}
-export function FilmStrip({ images, onOpenLightbox, onToggleFavorite }: FilmStripProps): ReactElement {
- if (images.length === 0) {
+export function FilmStrip({ images, onOpenLightbox, onToggleFavorite, activeGeneration, queuedCount }: FilmStripProps): ReactElement {
+ const hasContent = images.length > 0 || activeGeneration !== null || queuedCount > 0;
+
+ if (!hasContent) {
return Generated images appear here;
}
@@ -132,6 +263,10 @@ export function FilmStrip({ images, onOpenLightbox, onToggleFavorite }: FilmStri
return (
<>
+ {activeGeneration && (
+
+ )}
+ {Array.from({ length: queuedCount }, (_, i) => )}
{reversed.map((img, i) => (
onOpenLightbox(i)}
/>
+ {img.analysis?.ratingViolation && (
+
+ SFW VIOLATION
+
+ )}
{img.qualityScore !== undefined && (
)}