← back to hyunwoooim-star__autoshorts-mvp-

Function bodies 248 total

All specs Real LLM only Function bodies
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 });

    i
check 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_pos
getUrl 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)!;
    bucket
Repobility 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개)
- vid
buildSkeletonPrompt 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_wor
buildKoreanRewriteSystemPrompt 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-t
Want 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.queue
check 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",
        progres
getUrl 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