Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ OPENAI_API_KEY='your-openai-api-key-here'
# Google Gemini API key for AI text generation
GOOGLE_GENERATIVE_AI_API_KEY='your-gemini-api-key-here'

# Supabase Storage (for image uploads)
# Project URL: https://supabase.com/dashboard/project/_/settings/api
SUPABASE_URL=https://your-project-ref.supabase.co
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUPABASE_SERVICE_ROLE_KEY is a highly-privileged secret; since this file is a template that often gets copied into multiple environments, it would help to explicitly note that this key must only ever be used server-side (never in Expo/browser code) and should be kept out of any client-exposed env var mechanisms.

Suggested change
SUPABASE_URL=https://your-project-ref.supabase.co
SUPABASE_URL=https://your-project-ref.supabase.co
# WARNING: `SUPABASE_SERVICE_ROLE_KEY` is highly privileged and must only be used server-side.
# Never use it in Expo/browser code and never expose it via `EXPO_PUBLIC_*` or any other client-exposed env var mechanism.

Copilot uses AI. Check for mistakes.
# WARNING: `SUPABASE_SERVICE_ROLE_KEY` is highly privileged and must only be used server-side.
# Never use it in Expo/browser code and never expose it via `EXPO_PUBLIC_*` or any other client-exposed env var mechanism.
SUPABASE_SERVICE_ROLE_KEY=your_service_role_key_here
SUPABASE_STORAGE_BUCKET=images

# Expo app API URL (for local development, set to localhost:3000)
EXPO_PUBLIC_API_URL=http://localhost:3000

Expand Down
6 changes: 3 additions & 3 deletions apps/expo/src/app/(tabs)/feed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,11 +120,11 @@ export default function FeedScreen() {
{item.title}
</Text>

{/* Hybrid Image Display - prioritize AI-generated imageUri */}
{item.imageUri ? (
{/* Image display - prioritize AI-generated imageUrl */}
{item.imageUrl ? (
<Image
style={{ width: "100%", height: 200, borderRadius: rd.xl }}
source={{ uri: item.imageUri }}
source={{ uri: item.imageUrl }}
contentFit="cover"
transition={300}
/>
Expand Down
6 changes: 3 additions & 3 deletions apps/expo/src/app/(tabs)/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ interface ContentCard {
type: "bill" | "government_content" | "court_case" | "general";
isAIGenerated: boolean;
thumbnailUrl?: string;
imageUri?: string;
imageUrl?: string;
}

const _TYPE_LABELS: Record<ContentCard["type"], string> = {
Expand Down Expand Up @@ -137,10 +137,10 @@ const ContentCardComponent = ({
</View>

{/* Thumbnail */}
{(item.imageUri ?? item.thumbnailUrl) ? (
{(item.imageUrl ?? item.thumbnailUrl) ? (
<Image
style={styles.thumbnail}
source={{ uri: item.imageUri ?? item.thumbnailUrl }}
source={{ uri: item.imageUrl ?? item.thumbnailUrl }}
contentFit="cover"
transition={300}
/>
Expand Down
59 changes: 41 additions & 18 deletions apps/scraper/src/utils/db/video-operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import { db } from '@acme/db/client';
import { Video } from '@acme/db/schema';
import { uploadImage } from '@acme/db/storage';
import { and, eq } from '@acme/db';
import { generateMarketingCopy } from '../ai/marketing-generation.js';
import { generateImage, convertToJpeg } from '../ai/image-generation.js';
Expand Down Expand Up @@ -68,12 +69,11 @@ export async function generateVideoForContent(
// Generate marketing copy
const marketingCopy = await generateMarketingCopy(title, fullText, contentType);

// Generate and convert image
let imageData: Buffer | null = null;
let imageMimeType = 'image/jpeg';
// Generate and convert image (upload happens after DB write to avoid orphans)
let jpegData: Buffer | null = null;
const generatedImage = await generateImage(marketingCopy.imagePrompt);
if (generatedImage) {
imageData = await convertToJpeg(generatedImage.data);
jpegData = await convertToJpeg(generatedImage.data);
}

// Random engagement metrics (same as current video.ts)
Expand All @@ -83,7 +83,7 @@ export async function generateVideoForContent(
shares: Math.floor(Math.random() * 1000) + 10,
};

// Upsert video with hybrid image support
// Upsert video first (without image URL)
try {
await db
.insert(Video)
Expand All @@ -92,11 +92,7 @@ export async function generateVideoForContent(
contentId,
title: marketingCopy.title,
description: marketingCopy.description,
imageData,
imageMimeType,
imageWidth: imageData ? 1024 : null,
imageHeight: imageData ? 1024 : null,
thumbnailUrl: thumbnailUrl ?? undefined, // Add URL-based thumbnail support
thumbnailUrl: thumbnailUrl ?? undefined,
author,
engagementMetrics,
sourceContentHash: contentHash,
Expand All @@ -106,18 +102,11 @@ export async function generateVideoForContent(
set: {
title: marketingCopy.title,
description: marketingCopy.description,
imageData,
imageMimeType,
imageWidth: imageData ? 1024 : null,
imageHeight: imageData ? 1024 : null,
thumbnailUrl: thumbnailUrl ?? undefined, // Update thumbnail URL on conflict
thumbnailUrl: thumbnailUrl ?? undefined,
sourceContentHash: contentHash,
updatedAt: new Date(),
},
});

incrementVideosGenerated();
logger.success(`Video generated for ${contentType}:${contentId}`);
} catch (error) {
// Sanitize error to avoid logging raw image data
const sanitizedError = error instanceof Error
Expand All @@ -126,4 +115,38 @@ export async function generateVideoForContent(
logger.error(`Failed to insert video for ${contentType}:${contentId}: ${sanitizedError}`);
throw error;
}

// Upload image after successful DB write, then update the row
if (jpegData) {
const storagePath = `videos/${contentType}/${contentId}.jpg`;
let imageUrl: string | undefined;
try {
imageUrl = await uploadImage(storagePath, jpegData);
} catch (error) {
logger.warn(`Image upload failed for ${contentType}:${contentId}, video saved without image`);
}
if (imageUrl) {
try {
await db
.update(Video)
.set({
imageUrl,
imageData: null,
imageMimeType: null,
imageWidth: null,
imageHeight: null,
})
.where(and(eq(Video.contentType, contentType), eq(Video.contentId, contentId)));
logger.debug(`Uploaded image to ${storagePath}`);
} catch (error) {
// Don't delete the uploaded file — it lives at a deterministic path that
// may already be referenced by a previous imageUrl, and will be
// overwritten on the next successful run.
logger.warn(`DB update for imageUrl failed for ${contentType}:${contentId}, image uploaded but URL not saved`);
}
}
}

incrementVideosGenerated();
logger.success(`Video generated for ${contentType}:${contentId}`);
}
218 changes: 0 additions & 218 deletions docs/IMAGE_INTEGRATION.md

This file was deleted.

2 changes: 1 addition & 1 deletion packages/api/src/router/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ const ContentCardSchema = z.object({
type: z.enum(["bill", "government_content", "court_case", "general"]),
isAIGenerated: z.boolean(),
thumbnailUrl: z.string().optional(),
imageUri: z.string().optional(), // Add support for AI-generated data URIs
imageUrl: z.string().optional(),
});

export type ContentCard = z.infer<typeof ContentCardSchema>;
Expand Down
Loading
Loading