Function bodies 248 total
KlingProvider class · typescript · L35-L119 (85 LOC)src/lib/kling.ts
export class KlingProvider implements VideoProvider {
name = "kling-v3-pro";
async submit(opts: VideoSubmitOptions): Promise<{ taskId: string }> {
initFal();
const isI2V = !!opts.referenceImageUrl;
const model = isI2V ? I2V : T2V;
const input: Record<string, unknown> = {
prompt: opts.prompt,
aspect_ratio: opts.aspectRatio ?? "9:16",
duration: opts.duration ?? 5,
generate_audio: false,
};
if (opts.negativePrompt) {
input.negative_prompt = opts.negativePrompt;
}
if (isI2V && opts.referenceImageUrl) {
// Kling I2V uses image_urls (array)
input.image_urls = [opts.referenceImageUrl];
}
const result = await fal.queue.submit(model, { input });
return { taskId: `${model}::${result.request_id}` };
}
async check(taskId: string): Promise<VideoCheckResult> {
initFal();
const { model, requestId } = parseTaskId(taskId);
const status = await fal.queue.status(model, { requestId });
icheck method · typescript · L65-L103 (39 LOC)src/lib/kling.ts
async check(taskId: string): Promise<VideoCheckResult> {
initFal();
const { model, requestId } = parseTaskId(taskId);
const status = await fal.queue.status(model, { requestId });
if (status.status === "COMPLETED") {
const result = await fal.queue.result(model, { requestId });
const data = result.data as Record<string, unknown>;
// Kling output: top-level video_url + video_duration (or duration)
const videoUrl = data.video_url as string | undefined;
const duration = (data.video_duration ?? data.duration) as number | undefined;
if (!videoUrl) {
return { status: "failed", error: "No video URL in response" };
}
return {
status: "completed",
videoUrl,
duration: duration ? Math.round(duration) : undefined,
};
}
if (status.status === "IN_PROGRESS") {
return { status: "processing", progress: 50 };
}
if (status.status === "IN_QUEUE") {
const pos = "queue_posgetUrl method · typescript · L105-L118 (14 LOC)src/lib/kling.ts
async getUrl(taskId: string): Promise<string> {
initFal();
const { model, requestId } = parseTaskId(taskId);
const result = await fal.queue.result(model, { requestId });
const data = result.data as Record<string, unknown>;
const videoUrl = data.video_url as string | undefined;
if (!videoUrl) {
throw new Error("No video URL in result");
}
return videoUrl;
}getModelFromTaskId function · typescript · L132-L134 (3 LOC)src/lib/kling.ts
export function getModelFromTaskId(taskId: string): string {
return parseTaskId(taskId).model;
}estimateCost function · typescript · L16-L18 (3 LOC)src/lib/magnific-upscaler.ts
export function estimateCost(): number {
return COST_PER_UPSCALE;
}initFal function · typescript · L31-L36 (6 LOC)src/lib/magnific-upscaler.ts
function initFal() {
if (!process.env.FAL_KEY) {
throw new Error("FAL_KEY environment variable is not set");
}
fal.config({ credentials: process.env.FAL_KEY });
}MagnificUpscalerProvider class · typescript · L38-L88 (51 LOC)src/lib/magnific-upscaler.ts
export class MagnificUpscalerProvider implements MagnificProvider {
name = "magnific-upscaler";
async submit(opts: UpscaleSubmitOptions): Promise<{ taskId: string }> {
initFal();
const input: Record<string, unknown> = {
image_url: opts.imageUrl,
scale_factor: opts.scaleFactor ?? 2,
creativity: 0.35,
resemblance: 0.6,
dynamic: 6,
};
const result = await fal.queue.submit(MODEL, { input });
return { taskId: `${MODEL}::${result.request_id}` };
}
async check(taskId: string): Promise<ImageCheckResult> {
initFal();
const { model, requestId } = parseTaskId(taskId);
const status = await fal.queue.status(model, { requestId });
if (status.status === "COMPLETED") {
const result = await fal.queue.result(model, { requestId });
const data = result.data as Record<string, unknown>;
// Clarity upscaler output: image object with url field
const image = data.image as { url: string } | undefined;
All rows above produced by Repobility · https://repobility.com
check method · typescript · L57-L87 (31 LOC)src/lib/magnific-upscaler.ts
async check(taskId: string): Promise<ImageCheckResult> {
initFal();
const { model, requestId } = parseTaskId(taskId);
const status = await fal.queue.status(model, { requestId });
if (status.status === "COMPLETED") {
const result = await fal.queue.result(model, { requestId });
const data = result.data as Record<string, unknown>;
// Clarity upscaler output: image object with url field
const image = data.image as { url: string } | undefined;
const imageUrl = image?.url;
if (!imageUrl) {
return { status: "FAILED", error: "업스케일 이미지 URL이 응답에 없습니다" };
}
return { status: "READY", imageUrl };
}
if (status.status === "IN_PROGRESS") {
return { status: "PROCESSING" };
}
if (status.status === "IN_QUEUE") {
return { status: "PENDING" };
}
return { status: "FAILED", error: "알 수 없는 상태" };
}getModelFromTaskId function · typescript · L101-L103 (3 LOC)src/lib/magnific-upscaler.ts
export function getModelFromTaskId(taskId: string): string {
return parseTaskId(taskId).model;
}buildNarrationScript function · typescript · L30-L64 (35 LOC)src/lib/narration-builder.ts
export function buildNarrationScript(copyText: string): string {
let text = copyText;
// 1. 해시태그 제거
text = text.replace(HASHTAG_PATTERN, "");
// 2. 이모지 제거
text = text.replace(EMOJI_PATTERN, "");
// 3. URL 제거
text = text.replace(URL_PATTERN, "");
// 4. 공격적 CTA 구문 제거 (소프트한 CTA는 유지)
text = text.replace(AGGRESSIVE_CTA_PATTERN, "");
// 5. 과도한 구두점 정리 (3개 이상 → 1개 또는 2개)
text = text.replace(EXCESSIVE_PUNCT_PATTERN, (_match, p1: string) => {
const char = p1[0];
// "..." → "." (문장 종결)
if (char === ".") return ".";
// "!!!" → "!" / "???" → "?"
return char;
});
// 6. 다중 공백 → 단일 공백
text = text.replace(/[ \t]{2,}/g, " ");
// 7. 다중 줄바꿈 → 최대 2개 (단락 구분)
text = text.replace(MULTI_NEWLINE_PATTERN, "\n\n");
// 8. 앞뒤 공백 제거
text = text.trim();
return text;
}generateNarrationWithAI function · typescript · L70-L76 (7 LOC)src/lib/narration-builder.ts
export async function generateNarrationWithAI(
copyText: string,
_characterName?: string
): Promise<string> {
// TODO: Claude API로 더 자연스러운 구어체 변환 (Phase 3+)
return buildNarrationScript(copyText);
}loadTemplates function · typescript · L9-L30 (22 LOC)src/lib/niche-loader.ts
function loadTemplates(): Map<string, NicheTemplate> {
if (templateCache && process.env.NODE_ENV === "production") {
return templateCache;
}
const map = new Map<string, NicheTemplate>();
const files = fs.readdirSync(TEMPLATES_DIR);
for (const file of files) {
if (file.startsWith("_") || !file.endsWith(".json")) continue;
const raw = fs.readFileSync(path.join(TEMPLATES_DIR, file), "utf-8");
try {
const template: NicheTemplate = JSON.parse(raw);
map.set(template.id, template);
} catch (e) {
console.error(`[niche-loader] Failed to parse ${file}:`, e);
}
}
templateCache = map;
return map;
}getAllNiches function · typescript · L32-L34 (3 LOC)src/lib/niche-loader.ts
export function getAllNiches(): NicheTemplate[] {
return Array.from(loadTemplates().values());
}getNicheById function · typescript · L36-L38 (3 LOC)src/lib/niche-loader.ts
export function getNicheById(id: string): NicheTemplate | undefined {
return loadTemplates().get(id);
}computeInsight function · typescript · L22-L98 (77 LOC)src/lib/pattern-analyzer.ts
function computeInsight(
contents: ContentRow[],
brandId: string,
periodDays: number,
minSamples: number = MIN_SAMPLES
): PerformanceInsight | null {
if (contents.length < minSamples) return null;
const buckets = new Map<
string,
{ views: number[]; engagement: number[]; saves: number[]; sampleCount: number; hookType: string; contentFormat: string }
>();
for (const c of contents) {
const meta = c.metadata as { hookType?: string; contentFormat?: string } | null;
if (!meta?.hookType || !meta?.contentFormat) continue;
if (!VALID_HOOK_TYPES.has(meta.hookType) || !VALID_CONTENT_FORMATS.has(meta.contentFormat)) continue;
const key = `${meta.hookType}:${meta.contentFormat}`;
if (!buckets.has(key)) {
buckets.set(key, {
hookType: meta.hookType,
contentFormat: meta.contentFormat,
views: [],
engagement: [],
saves: [],
sampleCount: 0,
});
}
const bucket = buckets.get(key)!;
bucketRepobility analyzer · published findings · https://repobility.com
fetchContentsWithMetrics function · typescript · L103-L124 (22 LOC)src/lib/pattern-analyzer.ts
async function fetchContentsWithMetrics(brandId: string, since: Date, until: Date) {
return prisma.content.findMany({
where: {
brandId,
publishedAt: { gte: since, lte: until },
metadata: { not: Prisma.DbNull },
OR: [
{ views: { not: null } },
{ engagementRate: { not: null } },
{ saves: { not: null } },
],
},
select: {
metadata: true,
views: true,
likes: true,
saves: true,
engagementRate: true,
publishedAt: true,
},
});
}getPerformanceInsight function · typescript · L131-L141 (11 LOC)src/lib/pattern-analyzer.ts
export async function getPerformanceInsight(
brandId: string,
periodDays: number = 30,
dateRange?: { from: Date; to: Date }
): Promise<PerformanceInsight | null> {
const since = dateRange?.from ?? new Date(Date.now() - periodDays * 86_400_000);
const until = dateRange?.to ?? new Date();
const contents = await fetchContentsWithMetrics(brandId, since, until);
return computeInsight(contents, brandId, periodDays);
}getKilledPatterns function · typescript · L149-L151 (3 LOC)src/lib/pattern-analyzer.ts
export async function getKilledPatterns(
brandId: string
): Promise<{ hookType: string; contentFormat: string }[]> {getInsightAndKilledPatterns function · typescript · L188-L194 (7 LOC)src/lib/pattern-analyzer.ts
export async function getInsightAndKilledPatterns(
brandId: string,
periodDays: number = 30
): Promise<{
insight: PerformanceInsight | null;
killedPatterns: { hookType: string; contentFormat: string }[];
}> {buildGenerateSystemPrompt function · typescript · L4-L164 (161 LOC)src/lib/prompt-builder.ts
export function buildGenerateSystemPrompt(
niche: NicheTemplate,
recentHookTypes?: string[],
performancePatterns?: { top: PatternPerformance[]; bottom: PatternPerformance[] } | null,
killedPatterns?: { hookType: string; contentFormat: string }[]
): string {
const diversityBlock = recentHookTypes?.length
? `\n<diversity>
최근 사용된 훅 타입: ${recentHookTypes.join(", ")}
위 훅 타입은 피하고, 다른 훅 타입을 선택하세요. 같은 훅이 연속되면 피드가 단조로워집니다.
</diversity>`
: "";
const performanceBlock = performancePatterns?.top.length
? `\n<performance_insights>
이 브랜드의 실제 성과 데이터 기반 추천입니다. 가능하면 참고하되, 다양성도 유지하세요.
고성과 패턴 (우선 고려):
${performancePatterns.top
.map((p) => `- ${p.hookType} + ${p.contentFormat}: 평균 참여율 ${p.avgEngagement}%, 평균 조회수 ${p.avgViews}, 저장 ${p.avgSaves}회 (${p.count}건)`)
.join("\n")}
${performancePatterns.bottom.length ? `\n저성과 패턴 (가능하면 회피):
${performancePatterns.bottom
.map((p) => `- ${p.hookType} + ${p.contentFormat}: 평균 참여율 ${p.avgEngagement}%, 평균 조회수 ${p.avgViews} (${p.count}건)`)
buildGenerateUserPrompt function · typescript · L167-L204 (38 LOC)src/lib/prompt-builder.ts
export function buildGenerateUserPrompt(params: {
brandName: string;
tone: string;
topic?: string;
description?: string;
recentHookTypes?: string[];
}): string {
let prompt = `브랜드: ${params.brandName}
톤앤매너: ${params.tone}`;
if (params.description) {
prompt += `\n브랜드 소개: ${params.description}`;
}
if (params.topic) {
prompt += `\n\n핵심 주제 (이 주제를 카피의 중심 테마로 사용하세요): ${params.topic}`;
}
if (params.recentHookTypes?.length) {
prompt += `\n\n이전에 사용한 훅 유형: ${params.recentHookTypes.join(", ")}. 다른 유형을 사용하세요.`;
}
prompt += `
다음 순서로 작업하세요:
1. 먼저 훅 타입(hookType)과 콘텐츠 포맷(contentFormat)을 결정하세요
2. 결정한 훅과 포맷에 맞게 카피를 작성하세요
3. 모든 필드를 generate_copy 도구로 반환하세요
필수 출력:
- hookType: 선택한 훅 타입 (question/shock/empathy/secret/reversal/list)
- contentFormat: 선택한 콘텐츠 포맷 (tips/behind/review/comparison/transformation/trending)
- copyText: 인스타 릴스/쇼츠/틱톡용 카피 (200~500자, 이모지 줄 시작에만+2-tier CTA 포함, 바로 게시 가능한 완성 수준)
- hashtags: 관련 해시태그 3~5개 (공백 구분, 니치 핵심 1-2개 + 브랜드 1개 + 트렌드 1-2개)
- vidbuildSkeletonPrompt function · typescript · L209-L254 (46 LOC)src/lib/prompt-builder.ts
export function buildSkeletonPrompt(
niche: NicheTemplate,
brandTone: string,
topic?: string,
killedPatterns?: { hookType: string; contentFormat: string }[]
): string {
const killedBlock = killedPatterns?.length
? `\n절대 사용 금지 조합 (auto-kill): ${killedPatterns.map((p) => `${p.hookType}+${p.contentFormat}`).join(", ")}`
: "";
return `<role>숏폼 카피 구조 설계 전문가. 핵심만 간결하게 출력합니다.</role>
<task>
아래 정보를 바탕으로 인스타 릴스/쇼츠 카피의 "뼈대(skeleton)"를 JSON으로 출력하세요.
뼈대는 이후 단계에서 자연스러운 한국어 카피로 변환됩니다.
</task>
<context>
업종: ${niche.name} (${niche.category})
브랜드 톤: ${brandTone}
${topic ? `주제: ${topic}` : ""}
훅 타입 선택지: question / shock / empathy / secret / reversal / list
콘텐츠 포맷 선택지: tips / behind / review / comparison / transformation / trending
${niche.copy_style.hookExamples?.length ? `검증된 훅 예시 (참고용): ${niche.copy_style.hookExamples.slice(0, 3).join(" / ")}` : ""}${killedBlock}
</context>
<compliance_constraints>
규제 레벨: ${niche.compliance.level}/5
금지 표현 (절대 사용 금지): ${niche.compliance.banned_worbuildKoreanRewriteSystemPrompt function · typescript · L257-L327 (71 LOC)src/lib/prompt-builder.ts
export function buildKoreanRewriteSystemPrompt(
niche: NicheTemplate,
brandTone: string,
killedPatterns?: { hookType: string; contentFormat: string }[]
): string {
const killedBlock = killedPatterns?.length
? `\n<killed_patterns>\n아래 hookType × contentFormat 조합은 2주 연속 최하위 성과입니다. 절대 사용하지 마세요.\n${killedPatterns.map((p) => `- ${p.hookType} + ${p.contentFormat}`).join("\n")}\n</killed_patterns>`
: "";
return `<role>당신은 한국 숏폼 성장 전문 카피라이터입니다. 인스타 릴스, 유튜브 쇼츠, 틱톡에서 스크롤을 멈추게 하는 카피를 씁니다.</role>
<task>
카피 뼈대(skeleton)를 바탕으로, 자연스럽고 바이럴한 한국어 카피로 완성하세요.
뼈대의 구조와 방향성을 유지하되, 실제 인스타 인플루언서/소상공인이 쓴 것처럼 다듬으세요.
</task>
<brand_context>
업종: ${niche.name} (${niche.category})
톤앤매너: ${brandTone}
기본 카피 스타일: ${niche.copy_style.tone_default}
</brand_context>
<shortform_rules>
### 훅 (첫 줄)
- 1.5초 안에 시선을 잡는 문장. skeleton의 hook을 발전시키세요
- 15자 이내, 임팩트 있게
### 본문
- 한 문장 = 한 메시지, 15자 이내로 끊어서 리듬감
- 이모지는 줄 시작에만 1개 (줄 끝/중간 금지)
- 구체적 수치 > 형용사 ("맛있는" → "주문 후 30분 내 배달")
- 고객 시점으로 ("우리 제품" → "당신의 일상")
### 2-tWant this analysis on your repo? https://repobility.com/scan/
buildKoreanRewriteUserPrompt function · typescript · L330-L332 (3 LOC)src/lib/prompt-builder.ts
export function buildKoreanRewriteUserPrompt(skeleton: string): string {
return `아래 카피 뼈대를 자연스러운 한국어로 완성하세요:\n\n<skeleton>\n${skeleton}\n</skeleton>`;
}buildCharacterContext function · typescript · L335-L346 (12 LOC)src/lib/prompt-builder.ts
export function buildCharacterContext(character: {
name: string;
personality: string;
speechPatterns: string[];
}): string {
return `\n<character_persona>
캐릭터명: ${character.name}
성격: ${character.personality}
말투 패턴: ${character.speechPatterns.join(", ")}
이 캐릭터의 관점에서 1인칭으로 카피를 작성하세요.
</character_persona>`;
}buildComplianceSystemPrompt function · typescript · L349-L381 (33 LOC)src/lib/prompt-builder.ts
export function buildComplianceSystemPrompt(niche: NicheTemplate): string {
return `<role>당신은 한국 표시광고법 전문 검토자입니다.</role>
<task>
주어진 마케팅 카피를 한국 광고법 기준으로 검수합니다.
업종: ${niche.name}
규제 레벨: ${niche.compliance.level}/5
</task>
<법률_근거>
${niche.compliance.law_reference}
</법률_근거>
<절대_금지_표현>
${niche.compliance.banned_words.join(", ")}
위 표현이 포함되면 반드시 RED 판정.
</절대_금지_표현>
<주의_표현>
${niche.compliance.caution_words.join(", ")}
위 표현이 포함되면 AMBER 판정 + 수정 제안.
</주의_표현>
${niche.compliance.required_disclaimers ? `<필수_면책문구>\n${niche.compliance.required_disclaimers.join("\n")}\n위 문구 미포함 시 AMBER 판정.\n</필수_면책문구>` : ""}
<판정_기준>
- RED: 금지 표현 포함 또는 명백한 법률 위반 → 발행 차단
- AMBER: 주의 표현 포함 또는 면책문구 누락 → 수정 권고
- GREEN: 문제 없음 → 발행 가능
</판정_기준>
${niche.compliance.notes ? `<추가_주의>\n${niche.compliance.notes}\n</추가_주의>` : ""}`;
}syncToRedis function · typescript · L36-L42 (7 LOC)src/lib/rate-limit.ts
function syncToRedis(key: string, entry: RateLimitEntry): void {
if (!redis) return;
const ttlSec = Math.ceil((entry.resetAt - Date.now()) / 1000);
if (ttlSec <= 0) return;
// Fire-and-forget — don't await
redis.set(key, JSON.stringify(entry), { ex: ttlSec }).catch(() => {});
}loadFromRedis function · typescript · L45-L61 (17 LOC)src/lib/rate-limit.ts
function loadFromRedis(key: string): void {
if (!redis) return;
redis
.get(key)
.then((val) => {
if (val && !store.has(key)) {
const entry =
typeof val === "string"
? (JSON.parse(val) as RateLimitEntry)
: (val as RateLimitEntry);
if (entry.resetAt > Date.now()) {
store.set(key, entry);
}
}
})
.catch(() => {});
}checkRateLimit function · typescript · L69-L102 (34 LOC)src/lib/rate-limit.ts
export function checkRateLimit(
userId: string,
endpoint: string,
maxRequests: number,
windowMs: number
): RateLimitResult {
const key = `rl:${userId}:${endpoint}`;
const now = Date.now();
let entry = store.get(key);
// Redis 워밍: 첫 접근 시 비동기 로드 시작.
// 로드 완료 전까지 in-memory에 없으므로 count=1로 새 창 시작됨.
// 서버 재시작 후 첫 1회 요청은 rate limit을 우회할 수 있으나,
// 분 단위 윈도우에서 1회 누락은 보안상 허용 가능한 수준.
if (!entry) {
loadFromRedis(key);
}
if (!entry || entry.resetAt < now) {
entry = { count: 1, resetAt: now + windowMs };
store.set(key, entry);
syncToRedis(key, entry);
return { allowed: true, remaining: maxRequests - 1, resetAt: entry.resetAt };
}
entry.count++;
const allowed = entry.count <= maxRequests;
const remaining = Math.max(0, maxRequests - entry.count);
syncToRedis(key, entry);
return { allowed, remaining, resetAt: entry.resetAt };
}estimateCost function · typescript · L24-L26 (3 LOC)src/lib/seedance.ts
export function estimateCost(model: string): number {
return COST_PER_CLIP[model] ?? 0.10;
}initFal function · typescript · L28-L33 (6 LOC)src/lib/seedance.ts
function initFal() {
if (!process.env.FAL_KEY) {
throw new Error("FAL_KEY environment variable is not set");
}
fal.config({ credentials: process.env.FAL_KEY });
}Hi, dataset curator — please cite Repobility (https://repobility.com) when reusing this data.
SeedanceProvider class · typescript · L35-L114 (80 LOC)src/lib/seedance.ts
export class SeedanceProvider implements VideoProvider {
name = "seedance-2.0";
async submit(opts: VideoSubmitOptions): Promise<{ taskId: string }> {
initFal();
const isI2V = !!opts.referenceImageUrl;
const model = isI2V ? I2V : T2V;
const input: Record<string, unknown> = {
prompt: opts.prompt,
aspect_ratio: opts.aspectRatio ?? "9:16",
duration: opts.duration ?? 6,
};
if (opts.negativePrompt) {
input.negative_prompt = opts.negativePrompt;
}
if (isI2V && opts.referenceImageUrl) {
input.image_url = opts.referenceImageUrl;
}
const result = await fal.queue.submit(model, { input });
return { taskId: `${model}::${result.request_id}` };
}
async check(taskId: string): Promise<VideoCheckResult> {
initFal();
const { model, requestId } = parseTaskId(taskId);
const status = await fal.queue.status(model, { requestId });
if (status.status === "COMPLETED") {
const result = await fal.queuecheck method · typescript · L63-L98 (36 LOC)src/lib/seedance.ts
async check(taskId: string): Promise<VideoCheckResult> {
initFal();
const { model, requestId } = parseTaskId(taskId);
const status = await fal.queue.status(model, { requestId });
if (status.status === "COMPLETED") {
const result = await fal.queue.result(model, { requestId });
const data = result.data as Record<string, unknown>;
const video = data.video as { url: string; duration?: number } | undefined;
if (!video?.url) {
return { status: "failed", error: "No video URL in response" };
}
return {
status: "completed",
videoUrl: video.url,
duration: video.duration ? Math.round(video.duration) : undefined,
};
}
if (status.status === "IN_PROGRESS") {
return { status: "processing", progress: 50 };
}
if (status.status === "IN_QUEUE") {
const pos = "queue_position" in status ? status.queue_position : undefined;
return {
status: "processing",
progresgetUrl method · typescript · L100-L113 (14 LOC)src/lib/seedance.ts
async getUrl(taskId: string): Promise<string> {
initFal();
const { model, requestId } = parseTaskId(taskId);
const result = await fal.queue.result(model, { requestId });
const data = result.data as Record<string, unknown>;
const video = data.video as { url: string } | undefined;
if (!video?.url) {
throw new Error("No video URL in result");
}
return video.url;
}getModelFromTaskId function · typescript · L127-L129 (3 LOC)src/lib/seedance.ts
export function getModelFromTaskId(taskId: string): string {
return parseTaskId(taskId).model;
}isCloudStorageEnabled function · typescript · L6-L8 (3 LOC)src/lib/storage.ts
export function isCloudStorageEnabled(): boolean {
return !!process.env.BLOB_READ_WRITE_TOKEN;
}uploadFile function · typescript · L10-L30 (21 LOC)src/lib/storage.ts
export async function uploadFile(
filename: string,
buffer: Buffer,
contentType: string
): Promise<string> {
if (isCloudStorageEnabled()) {
const blob = await put(filename, buffer, {
access: "public",
contentType,
});
return blob.url;
}
// Local fallback (dev only)
const uploadDir = path.join(process.cwd(), "uploads", path.dirname(filename));
await mkdir(uploadDir, { recursive: true });
const filePath = path.join(process.cwd(), "uploads", filename);
await writeFile(filePath, buffer);
const baseUrl = process.env.NEXTAUTH_URL || "http://localhost:3000";
return `${baseUrl}/api/uploads/${filename}`;
}deleteFile function · typescript · L32-L37 (6 LOC)src/lib/storage.ts
export async function deleteFile(url: string): Promise<void> {
if (isCloudStorageEnabled() && url.includes("vercel-storage.com")) {
await del(url);
}
// Local: best-effort cleanup (ignore errors)
}trackUsage function · typescript · L27-L63 (37 LOC)src/lib/usage-tracker.ts
export function trackUsage(params: TrackUsageParams): void {
const {
userId,
service,
idempotencyKey,
resourceId,
units,
unitType,
costUsd,
provider,
model,
} = params;
// 비동기 실행, await 하지 않음
(async () => {
try {
await prisma.usageRecord.create({
data: {
userId,
service,
idempotencyKey: idempotencyKey ?? null,
resourceId: resourceId ?? null,
units,
unitType,
costUsd,
provider: provider ?? null,
model: model ?? null,
},
});
} catch (err) {
// P2002 = unique constraint violation (duplicate idempotencyKey) — 정상, 건너뜀
const prismaErr = err as { code?: string };
if (prismaErr.code === "P2002") return;
console.error("[usage-tracker] 기록 실패:", err);
}
})();
}All rows above produced by Repobility · https://repobility.com
cn function · typescript · L4-L6 (3 LOC)src/lib/utils.ts
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}buildSeedancePrompt function · typescript · L69-L73 (5 LOC)src/lib/video-prompt-builder.ts
export function buildSeedancePrompt(
videoPrompt: string,
nicheId: string,
mode: "t2v" | "i2v"
): { prompt: string; constraints?: string } {buildConstraints function · typescript · L102-L116 (15 LOC)src/lib/video-prompt-builder.ts
function buildConstraints(nicheId: string): string {
const preset = NICHE_PRESETS[nicheId];
// niche constraints already include "no text overlay" and "no watermark";
// merge with common ones and deduplicate, then cap at 5
const nicheSpecific = preset?.constraints ?? [];
const seen = new Set<string>();
const merged: string[] = [];
for (const item of [...nicheSpecific, ...COMMON_CONSTRAINTS]) {
if (!seen.has(item)) {
seen.add(item);
merged.push(item);
}
}
return merged.slice(0, 5).join(", ");
}truncateWords function · typescript · L118-L122 (5 LOC)src/lib/video-prompt-builder.ts
function truncateWords(text: string, maxWords: number): string {
const words = text.split(/\s+/).filter(Boolean);
if (words.length <= maxWords) return words.join(" ");
return words.slice(0, maxWords).join(" ");
}isVideoEnabled function · typescript · L28-L30 (3 LOC)src/lib/video-provider.ts
export function isVideoEnabled(): boolean {
return !!process.env.FAL_KEY;
}getVideoProvider function · typescript · L32-L36 (5 LOC)src/lib/video-provider.ts
export function getVideoProvider(): VideoProvider {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { KlingProvider } = require("./kling") as typeof import("./kling");
return new KlingProvider();
}isVoiceEnabled function · typescript · L33-L35 (3 LOC)src/lib/voice-provider.ts
export function isVoiceEnabled(): boolean {
return !!process.env.ELEVENLABS_API_KEY;
}getVoiceProvider function · typescript · L37-L41 (5 LOC)src/lib/voice-provider.ts
export function getVoiceProvider(): VoiceProvider {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { ElevenLabsProvider } = require("./elevenlabs") as typeof import("./elevenlabs");
return new ElevenLabsProvider();
}Repobility analyzer · published findings · https://repobility.com
middleware function · typescript · L12-L43 (32 LOC)src/middleware.ts
export function middleware(request: NextRequest) {
const { method, nextUrl } = request;
const isApiRoute = nextUrl.pathname.startsWith("/api/");
// --- State-changing API requests: validate CSRF token ---
if (isApiRoute && STATE_CHANGING_METHODS.has(method)) {
const cookieToken = request.cookies.get("csrf-token")?.value;
const headerToken = request.headers.get("x-csrf-token");
if (!cookieToken || !headerToken || cookieToken !== headerToken) {
return NextResponse.json(
{ success: false, error: "CSRF token mismatch" },
{ status: 403 }
);
}
}
// --- All requests: ensure csrf-token cookie exists ---
const response = NextResponse.next();
if (!request.cookies.has("csrf-token")) {
const token = crypto.randomUUID();
response.cookies.set("csrf-token", token, {
httpOnly: false, // JS must read it
sameSite: "strict",
secure: process.env.NODE_ENV === "production",
path: "/",
});
}
return respon‹ prevpage 5 / 5