| import os |
| import json |
| import base64 |
| import logging |
| import re |
| import subprocess |
| import tempfile |
| import time |
| import http.client |
| import urllib.request |
| import concurrent.futures |
| from urllib.parse import urlencode |
| from typing import Optional, Dict, Any, List, Callable |
|
|
| import replicate |
| from openai import OpenAI |
| from pydantic import BaseModel |
|
|
| from configurations import cnf |
|
|
| client = OpenAI(api_key=cnf.OPENAI_API_KEY) |
|
|
| if getattr(cnf, "REPLICATE_API_TOKEN", None): |
| os.environ["REPLICATE_API_TOKEN"] = cnf.REPLICATE_API_TOKEN |
|
|
| logging.basicConfig( |
| level=logging.INFO, |
| format="%(asctime)s | %(levelname)s | %(name)s | %(message)s" |
| ) |
|
|
| logger = logging.getLogger("ad_video_pipeline") |
|
|
|
|
| class SceneDirection(BaseModel): |
| scene_number: int |
| time_range: str |
| visual: str |
| avatar_action: str |
| product_placement: str |
| camera_direction: str |
| on_screen_text: str |
|
|
|
|
| class ScriptVisualDirectionResult(BaseModel): |
| duration_seconds: int |
| hook: str |
| voiceover_script: str |
| cta: str |
| visual_style: str |
| scene_breakdown: List[SceneDirection] |
| seedance_prompt: str |
| negative_prompt: str |
|
|
|
|
| class VoiceoverScriptRewrite(BaseModel): |
| voiceover_script: str |
|
|
|
|
| class StoryboardShot(BaseModel): |
| shot_number: int |
| time_range: str |
| title: str |
| camera: str |
| description: str |
| caption: str |
|
|
|
|
| class Storyboard(BaseModel): |
| ad_title: str |
| duration_seconds: int |
| mood: str |
| aspect_ratio: str |
| shots: List[StoryboardShot] |
|
|
|
|
| def as_url_list(value) -> List[str]: |
| if value is None: |
| return [] |
| if isinstance(value, str): |
| return [value] |
| return list(value) |
|
|
|
|
| def to_public_uri(url: Optional[str]) -> Optional[str]: |
| if not url: |
| return None |
|
|
| if url.startswith(("http://", "https://")): |
| return url |
|
|
| raise ValueError( |
| "This pipeline requires public http/https URLs. " |
| f"Got invalid value: {url}" |
| ) |
|
|
|
|
| def extract_voice_id(voice_clone_output: Any) -> str: |
| logger.info("[VOICE CLONE] Raw output type: %s", type(voice_clone_output)) |
|
|
| if isinstance(voice_clone_output, str): |
| return voice_clone_output |
|
|
| if isinstance(voice_clone_output, dict): |
| for key in ["voice_id", "id", "output"]: |
| value = voice_clone_output.get(key) |
|
|
| if isinstance(value, str) and value: |
| return value |
|
|
| if isinstance(value, dict): |
| nested_voice_id = value.get("voice_id") |
| if isinstance(nested_voice_id, str) and nested_voice_id: |
| return nested_voice_id |
|
|
| if isinstance(voice_clone_output, list) and voice_clone_output: |
| return extract_voice_id(voice_clone_output[0]) |
|
|
| raise ValueError( |
| f"Could not extract voice_id from MiniMax voice clone output: {voice_clone_output}" |
| ) |
|
|
|
|
| def extract_output_url(output: Any) -> str: |
| final_output = output[0] if isinstance(output, list) else output |
|
|
| if isinstance(final_output, str): |
| return final_output |
|
|
| output_url = getattr(final_output, "url", None) |
|
|
| if output_url: |
| return output_url |
|
|
| raise ValueError(f"Could not extract output URL from Replicate output: {output}") |
|
|
|
|
| def generate_script_and_visual_direction( |
| product_image_url: str, |
| angle: Optional[str] = None, |
| concept: Optional[str] = None, |
| avatar_description: Optional[str] = None, |
| voice_description: Optional[str] = None, |
| duration_seconds: int = 15, |
| model: str = "gpt-5.5", |
| manual_script: Optional[str] = None, |
| ) -> Dict[str, Any]: |
|
|
| logger.info("[STEP 1] Script + visual direction generation started") |
|
|
| product_image_urls = as_url_list(product_image_url) |
|
|
| hook_end = round(duration_seconds * 0.2) |
| product_end = round(duration_seconds * 0.47) |
| benefit_end = round(duration_seconds * 0.73) |
| payoff_end = round(duration_seconds * 0.93) |
|
|
| |
| |
| |
| |
| target_words = round(duration_seconds * 2.5) |
| min_words = round(target_words * 0.85) |
| max_words = round(target_words * 1.05) |
|
|
| manual_script_block = ( |
| "Voiceover Script (use this exact script verbatim — do not rewrite, shorten, or " |
| "paraphrase it; build the hook, CTA, and scene-by-scene visual direction around it):\n" |
| f'"""\n{manual_script}\n"""\n' |
| ) if manual_script else "" |
|
|
| prompt = f""" |
| You are an expert performance marketing creative strategist and product image analyst. |
| |
| Analyze the product image(s) and create a short video ad script and visual direction for Seedance video generation. |
| |
| Marketing Angle: |
| {angle or "Not provided"} |
| |
| Creative Concept: |
| {concept or "Not provided"} |
| |
| Avatar Description: |
| {avatar_description or "Not provided"} |
| |
| Voice Description: |
| {voice_description or "Not provided"} |
| |
| Video Duration: |
| {duration_seconds} seconds |
| |
| {manual_script_block} |
| First infer from the product image: |
| - product category |
| - product type |
| - visible features |
| - likely use case |
| - likely target audience |
| - main benefit |
| |
| Then generate: |
| - hook |
| - voiceover script{" (reuse the exact script given above verbatim)" if manual_script else ""} |
| - CTA |
| - visual style |
| - scene-by-scene visual direction |
| - final Seedance prompt |
| - negative prompt |
| |
| Seedance prompt rules: |
| - Refer to the avatar/person and the product generically (e.g. "the avatar", "the product") — do not assume specific [ImageN] index numbers, those are assigned separately based on the actual reference images supplied. |
| - Use [Audio1] as the final generated voiceover timing and pacing guide. |
| - Keep the avatar face, outfit, and body proportions consistent. |
| - Keep the product shape, packaging, color, and branding consistent. |
| - Show the product clearly. |
| - Make the ad realistic and social-media ready. |
| - Do not change the product design. |
| - Do not change the avatar face or outfit/clothing. |
| - When describing the CTA text overlay, quote it verbatim in double quotes (e.g. text overlay reads "SHOP NOW") and reuse that exact spelling everywhere it appears. Keep it short (under 5 words) so it renders legibly and spelled correctly. |
| - Do not caption or subtitle the voiceover on screen — the voiceover script is heard only, never shown as text. The CTA is the only on-screen text, shown once near the end. |
| |
| Ad structure for {duration_seconds} seconds: |
| 0-{hook_end}s: scroll-stopping problem hook. |
| {hook_end}-{product_end}s: product appears as the solution. |
| {product_end}-{benefit_end}s: demonstrate the key benefit. |
| {benefit_end}-{payoff_end}s: emotional payoff or trust point. |
| {payoff_end}-{duration_seconds}s: clear CTA, shown in the last shot of the video, not before. |
| |
| Rules: |
| {'- Use the voiceover script given above exactly as written — do not alter it.' if manual_script else f'''- Write the voiceover script to fill the full {duration_seconds} seconds at a |
| natural speaking pace — that's approximately {min_words}-{max_words} words. |
| Do not write a noticeably shorter script that leaves the back half of the |
| video silent; do not run over {max_words} words either.'''} |
| - Use simple, direct language. |
| - Do not make unverifiable claims. |
| - Do not mention discounts unless provided. |
| - Avoid medical, legal, or guaranteed claims. |
| - Keep visuals simple enough for a video generation model. |
| """ |
|
|
| try: |
| response = client.responses.parse( |
| model=model, |
| input=[ |
| { |
| "role": "system", |
| "content": ( |
| "You generate structured direct-response ad scripts and video prompts " |
| "for Bytedance Seedance 2.0. Return only output matching the schema." |
| ) |
| }, |
| { |
| "role": "user", |
| "content": [ |
| { |
| "type": "input_text", |
| "text": prompt |
| }, |
| *[ |
| {"type": "input_image", "image_url": url} |
| for url in product_image_urls |
| ] |
| ] |
| } |
| ], |
| text_format=ScriptVisualDirectionResult, |
| ) |
|
|
| creative = response.output_parsed.model_dump() |
|
|
| if manual_script: |
| creative["voiceover_script"] = manual_script |
|
|
| logger.info("[STEP 1] Script + visual direction generation completed") |
| logger.info("Hook: %s", creative.get("hook")) |
| logger.info("Voiceover script: %s", creative.get("voiceover_script")) |
| logger.info( |
| "Seedance prompt length: %s", |
| len(creative.get("seedance_prompt", "")) |
| ) |
|
|
| return creative |
|
|
| except Exception: |
| logger.exception("[STEP 1] Script + visual direction generation failed") |
| raise |
|
|
|
|
| def rewrite_voiceover_script_for_length( |
| creative: Dict[str, Any], |
| target_words: int, |
| model: str = "gpt-5.5", |
| ) -> str: |
| logger.info("[STEP 2C] Rewriting voiceover script to hit ~%s words", target_words) |
|
|
| prompt = f""" |
| This voiceover script was written for a video ad, but the synthesized speech |
| runs shorter than the video's actual duration — it needs to be longer. |
| Rewrite it to be approximately {target_words} words (natural, conversational |
| ad copy — do not pad with filler, repeated lines, or slow it down artificially; |
| add genuine additional content: more benefit detail, a supporting sentence, |
| etc.), while keeping the same hook, message, tone, and CTA meaning. |
| |
| Original script: |
| {creative.get("voiceover_script")} |
| |
| Hook: {creative.get("hook")} |
| CTA: {creative.get("cta")} |
| """ |
|
|
| try: |
| response = client.responses.parse( |
| model=model, |
| input=[ |
| { |
| "role": "system", |
| "content": ( |
| "You rewrite ad voiceover scripts to hit a target word count while " |
| "preserving meaning and tone. Return only output matching the schema." |
| ) |
| }, |
| { |
| "role": "user", |
| "content": prompt |
| } |
| ], |
| text_format=VoiceoverScriptRewrite, |
| ) |
|
|
| rewritten = response.output_parsed.voiceover_script |
|
|
| logger.info("[STEP 2C] Voiceover script rewritten (%s words)", len(rewritten.split())) |
|
|
| return rewritten |
|
|
| except Exception: |
| logger.exception("[STEP 2C] Voiceover script rewrite failed") |
| raise |
|
|
|
|
| def ensure_voiceover_fills_duration( |
| creative: Dict[str, Any], |
| generated_audio_url: str, |
| voice_id: str, |
| duration_seconds: int, |
| script_model: str = "gpt-5.5", |
| max_attempts: int = 3, |
| allow_rewrite: bool = True, |
| ) -> str: |
| |
| |
| |
| |
| |
| |
| |
| |
| if not allow_rewrite: |
| return generated_audio_url |
|
|
| for attempt in range(1, max_attempts + 1): |
| actual_audio_seconds = _get_audio_duration_seconds(download_image_bytes(generated_audio_url)) |
|
|
| if actual_audio_seconds >= duration_seconds * 0.9: |
| return generated_audio_url |
|
|
| if attempt == max_attempts: |
| logger.warning( |
| "Voiceover audio still short (%.1fs vs %ss) after %s attempts — proceeding anyway", |
| actual_audio_seconds, duration_seconds, max_attempts, |
| ) |
| return generated_audio_url |
|
|
| logger.info( |
| "Voiceover audio is %.1fs, short of the requested %ss (attempt %s/%s) — regenerating with a longer script", |
| actual_audio_seconds, duration_seconds, attempt, max_attempts, |
| ) |
|
|
| original_words = len(creative["voiceover_script"].split()) |
| adjusted_target_words = round(original_words * (duration_seconds / actual_audio_seconds)) |
|
|
| creative["voiceover_script"] = rewrite_voiceover_script_for_length( |
| creative=creative, |
| target_words=adjusted_target_words, |
| model=script_model, |
| ) |
| generated_audio_url = generate_speech_with_minimax( |
| text=creative["voiceover_script"], |
| voice_id=voice_id, |
| ) |
|
|
| return generated_audio_url |
|
|
|
|
| def generate_storyboard( |
| creative: Dict[str, Any], |
| duration_seconds: int, |
| model: str = "gpt-5.5", |
| window_start: Optional[int] = None, |
| window_end: Optional[int] = None, |
| prior_shots: Optional[List[Dict[str, Any]]] = None, |
| aspect_ratio: str = "9:16", |
| ) -> Dict[str, Any]: |
|
|
| logger.info("[STEP 1B] Storyboard generation started") |
|
|
| is_scoped = window_start is not None and window_end is not None |
| shots_start = window_start if is_scoped else 0 |
| shots_end = window_end if is_scoped else duration_seconds |
| is_final_window = shots_end >= duration_seconds |
|
|
| scope_block = "" |
| if is_scoped: |
| prior_block = ( |
| "\n".join(f"- {s['title']}: {s['description']}" for s in prior_shots) |
| if prior_shots else "(none — this is the first segment)" |
| ) |
| scope_block = f""" |
| This call covers ONLY the {shots_start}-{shots_end} second window of the full |
| {duration_seconds}-second ad, not the whole ad. Give shot time_range values as |
| absolute seconds within the full {duration_seconds}s timeline (e.g. shots in |
| this window look like "{shots_start}-{shots_start + 3}s", not restarting at 0). |
| |
| Shots already covered in earlier segments — do not repeat these beats, |
| continue the story forward from here: |
| {prior_block} |
| """ |
|
|
| cta_rule = ( |
| f"The CTA must only appear as on-screen text in the final shot, ending " |
| f"exactly at {duration_seconds}s — never earlier." |
| if is_final_window else |
| "Do not show the CTA in this segment — it belongs only in the ad's final segment." |
| ) |
|
|
| orientation = { |
| "9:16": "vertical", "3:4": "vertical", |
| "16:9": "horizontal", "21:9": "horizontal", "4:3": "horizontal", |
| "1:1": "square", |
| }.get(aspect_ratio, "vertical") |
|
|
| prompt = f""" |
| You are a commercial video storyboard artist. Break the ad script and visual |
| direction below into a shot-by-shot storyboard for a {duration_seconds}-second |
| {orientation} ({aspect_ratio}) social video, in the style of a professional shot list. |
| {scope_block} |
| Hook: {creative.get("hook")} |
| Voiceover script: {creative.get("voiceover_script")} |
| Visual style: {creative.get("visual_style")} |
| Scene breakdown: {json.dumps(creative.get("scene_breakdown", []))} |
| Seedance prompt: {creative.get("seedance_prompt")} |
| |
| Produce enough shots to cover the {shots_end - shots_start} seconds of {"this window" if is_scoped else "the ad"} |
| (roughly one shot per 2-3 seconds), with shot time ranges that are contiguous |
| and cover {shots_start} to {shots_end} with no gaps or overlaps. For each shot give: |
| - a time_range in exact "START-ENDs" format (e.g. "0-3s"), matching the shot's |
| place in the overall {duration_seconds}-second timeline |
| - a short punchy title (2-5 words, ALL CAPS style) |
| - a camera direction line (framing, movement, angle, handheld/steady) written |
| like a director's note |
| - a one-sentence description of the action/visual in the shot |
| - a one-sentence caption explaining why this shot matters to the ad |
| |
| {cta_rule} |
| |
| Keep each shot's hand action simple and singular — the avatar's hands should |
| do at most one thing at a time (e.g. hold the product, OR adjust an outfit, |
| OR gesture). Never describe the avatar holding more than one object at once |
| or performing two distinct hand actions simultaneously (e.g. "holding two |
| necklaces while checking a watch") — these poses are hard to render correctly |
| and tend to produce extra or malformed hands. |
| |
| Also give an overall ad_title, mood (3-4 words), and aspect_ratio ("{aspect_ratio}"). |
| """ |
|
|
| try: |
| response = client.responses.parse( |
| model=model, |
| input=[ |
| { |
| "role": "system", |
| "content": ( |
| "You produce structured shot-by-shot video storyboards. " |
| "Return only output matching the schema." |
| ) |
| }, |
| { |
| "role": "user", |
| "content": prompt |
| } |
| ], |
| text_format=Storyboard, |
| ) |
|
|
| storyboard = response.output_parsed.model_dump() |
|
|
| log_storyboard(storyboard) |
|
|
| logger.info("[STEP 1B] Storyboard generation completed") |
|
|
| return storyboard |
|
|
| except Exception: |
| logger.exception("[STEP 1B] Storyboard generation failed") |
| raise |
|
|
|
|
| def log_storyboard(storyboard: Dict[str, Any]) -> None: |
| logger.info("[STORYBOARD] Ad title: %s", storyboard.get("ad_title")) |
| logger.info( |
| "[STORYBOARD] Duration: %ss | Mood: %s | Aspect ratio: %s", |
| storyboard.get("duration_seconds"), |
| storyboard.get("mood"), |
| storyboard.get("aspect_ratio"), |
| ) |
| logger.info("[STORYBOARD] Generated %s shots", len(storyboard.get("shots", []))) |
|
|
| for shot in storyboard.get("shots", []): |
| logger.info( |
| "[STORYBOARD] Shot %s - %s | Camera: %s | %s | (%s)", |
| shot.get("shot_number"), |
| shot.get("title"), |
| shot.get("camera"), |
| shot.get("description"), |
| shot.get("caption"), |
| ) |
|
|
|
|
| def download_image_bytes(url: str) -> bytes: |
| request = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) |
| with urllib.request.urlopen(request, timeout=30) as resp: |
| return resp.read() |
|
|
|
|
| def generate_storyboard_poster( |
| storyboard: Dict[str, Any], |
| avatar_image_url: str, |
| product_image_url: str, |
| model: str = "gpt-image-2", |
| ) -> str: |
| logger.info("[STEP 1B] Generating full storyboard poster in one request (%s)", model) |
|
|
| avatar_urls = as_url_list(avatar_image_url) |
| product_urls = as_url_list(product_image_url) |
|
|
| avatar_bytes_list = [download_image_bytes(url) for url in avatar_urls] |
| product_bytes_list = [download_image_bytes(url) for url in product_urls] |
|
|
| avatar_ref_range = "reference image 1" if len(avatar_urls) == 1 else f"reference images 1-{len(avatar_urls)}" |
| product_start = len(avatar_urls) + 1 |
| product_end = len(avatar_urls) + len(product_urls) |
| product_ref_range = ( |
| f"reference image {product_start}" if len(product_urls) == 1 |
| else f"reference images {product_start}-{product_end}" |
| ) |
|
|
| shots = storyboard["shots"] |
| panels = "\n".join( |
| f'Panel {shot["shot_number"]} — "{shot["title"]}": {shot["description"]} ' |
| f'Camera: {shot["camera"]} Caption: {shot["caption"]}' |
| for shot in shots |
| ) |
|
|
| prompt = ( |
| f'Design one wide storyboard reference sheet on a dark background, in the style of ' |
| f'a professional shot-list infographic. Header bar reads "{storyboard["ad_title"].upper()} ' |
| f'— {len(shots)} SHOTS". Arrange all {len(shots)} panels in a grid (3 per row), each ' |
| "with a bold yellow numbered badge, a short bold title, a photo depicting that panel's " |
| "action, a yellow \"CAMERA:\" line with the camera direction below the photo, and a " |
| "muted caption line below that. Only the pose, action, and camera framing change per " |
| "panel to match its description — everything else about the person and product must " |
| "stay identical to the reference images in every panel:\n" |
| f"- Person ({avatar_ref_range}): same face, same facial features, same skin tone, " |
| "same hair, same body proportions and height, same outfit — do not regenerate a " |
| "different-looking person, even in close-up or cropped panels. If there is more than " |
| "one person reference image, they show the same person from different angles.\n" |
| f"- Product ({product_ref_range}): same design, shape, color, materials, and exact " |
| "proportions (e.g. pendant size relative to chain, gemstone count and layout, chain " |
| "length) — do not redesign, resize, or simplify the product. A macro/close-up panel may " |
| "fill more of the frame with the product because the camera is closer, but the product " |
| "itself, at its real-world scale relative to the person's hand or neck, must not be " |
| "drawn larger, smaller, or differently designed than the reference image.\n" |
| "- Anatomy: every person must have exactly two hands and two arms, five fingers per " |
| "hand, in correct proportion — no extra, duplicated, merged, or malformed hands/limbs " |
| "in any panel, even in busy or cropped compositions.\n\n" |
| f'Footer bar reads "DURATION: ~{storyboard["duration_seconds"]} SECONDS | MOOD: ' |
| f'{storyboard["mood"].upper()} | ASPECT RATIO: {storyboard["aspect_ratio"]}".\n\n' |
| f"Panels:\n{panels}" |
| ) |
|
|
| try: |
| response = client.images.edit( |
| image=[ |
| (f"avatar{i}.jpg", b, "image/jpeg") |
| for i, b in enumerate(avatar_bytes_list) |
| ] + [ |
| (f"product{i}.jpg", b, "image/jpeg") |
| for i, b in enumerate(product_bytes_list) |
| ], |
| prompt=prompt, |
| model=model, |
| size="1536x1024", |
| n=1, |
| ) |
|
|
| poster_image_url = f"data:image/png;base64,{response.data[0].b64_json}" |
|
|
| logger.info("[STEP 1B] Storyboard poster generated") |
|
|
| return poster_image_url |
|
|
| except Exception: |
| logger.exception("[STEP 1B] Storyboard poster generation failed") |
| raise |
|
|
|
|
| def clone_voice_with_minimax( |
| voice_public_url: str, |
| need_noise_reduction: bool = False |
| ) -> str: |
|
|
| logger.info("[STEP 2A] MiniMax voice cloning started") |
| logger.info("[STEP 2A] Voice file URL: %s", voice_public_url) |
|
|
| voice_public_url = to_public_uri(voice_public_url) |
|
|
| try: |
| output = replicate.run( |
| "minimax/voice-cloning", |
| input={ |
| "voice_file": voice_public_url, |
| "need_noise_reduction": need_noise_reduction |
| } |
| ) |
|
|
| voice_id = extract_voice_id(output) |
|
|
| logger.info("[STEP 2A] MiniMax voice cloning completed") |
| logger.info("[STEP 2A] Voice ID: %s", voice_id) |
|
|
| return voice_id |
|
|
| except Exception: |
| logger.exception("[STEP 2A] MiniMax voice cloning failed") |
| raise |
|
|
|
|
| def generate_speech_with_minimax( |
| text: str, |
| voice_id: str, |
| ) -> str: |
|
|
| logger.info("[STEP 2B] MiniMax speech generation started") |
| logger.info("[STEP 2B] Voice ID: %s", voice_id) |
| logger.info("[STEP 2B] Text length: %s chars", len(text)) |
|
|
| try: |
| output = replicate.run( |
| "minimax/speech-2.8-turbo", |
| input={ |
| "text": text, |
| "voice_id": voice_id |
| } |
| ) |
|
|
| generated_audio_url = extract_output_url(output) |
|
|
| logger.info("[STEP 2B] MiniMax speech generation completed") |
| logger.info("[STEP 2B] Generated audio URL: %s", generated_audio_url) |
|
|
| return generated_audio_url |
|
|
| except Exception: |
| logger.exception("[STEP 2B] MiniMax speech generation failed") |
| raise |
|
|
|
|
| def generate_audio_from_voice_url( |
| voiceover_script: str, |
| voice_public_url: str |
| ) -> Dict[str, str]: |
|
|
| logger.info("Full MiniMax audio generation started") |
| logger.info("Voice public URL: %s", voice_public_url) |
|
|
| try: |
| if voice_public_url.startswith(("http://", "https://")): |
| voice_id = clone_voice_with_minimax( |
| voice_public_url=voice_public_url, |
| need_noise_reduction=False |
| ) |
| else: |
| voice_id = voice_public_url |
| logger.info("[STEP 2A] Skipping voice cloning, using existing voice ID: %s", voice_id) |
|
|
| generated_audio_url = generate_speech_with_minimax( |
| text=voiceover_script, |
| voice_id=voice_id, |
| ) |
|
|
| logger.info("Full MiniMax audio generation completed") |
| logger.info("Voice ID: %s", voice_id) |
| logger.info("Generated audio URL: %s", generated_audio_url) |
|
|
| return { |
| "voice_id": voice_id, |
| "generated_audio_url": generated_audio_url |
| } |
|
|
| except Exception: |
| logger.exception("Full MiniMax audio generation failed") |
| raise |
|
|
|
|
| KIE_HOST = "api.kie.ai" |
| KIE_CREATE_TASK_PATH = "/api/v1/jobs/createTask" |
| KIE_RECORD_INFO_PATH = "/api/v1/jobs/recordInfo" |
|
|
| |
| KIE_UPLOAD_HOST = "kieai.redpandaai.co" |
| KIE_FILE_BASE64_UPLOAD_PATH = "/api/file-base64-upload" |
|
|
| |
| |
| AMALFA_LOGO_URL = "https://creative-library-beta.static.fabfunnel.com/330/fabAds/537/56b468f6-6720-4db1-b15e-725a623ddb84.jpg" |
|
|
|
|
| def get_kie_api_token() -> str: |
| token = ( |
| getattr(cnf, "KIE_API_TOKEN", None) |
| or getattr(cnf, "KIE_API_KEY", None) |
| or os.getenv("KIE_API_TOKEN") |
| or os.getenv("KIE_API_KEY") |
| ) |
|
|
| if not token: |
| raise ValueError( |
| "Missing Kie API token. Add KIE_API_TOKEN or KIE_API_KEY to your config/env." |
| ) |
|
|
| return token |
|
|
|
|
| def kie_api_request( |
| method: str, |
| path: str, |
| payload: Optional[Dict[str, Any]] = None, |
| query: Optional[Dict[str, Any]] = None, |
| host: str = KIE_HOST, |
| ) -> Dict[str, Any]: |
|
|
| if query: |
| path = f"{path}?{urlencode(query)}" |
|
|
| body = json.dumps(payload) if payload is not None else None |
|
|
| headers = { |
| "Authorization": f"Bearer {get_kie_api_token()}", |
| } |
|
|
| if payload is not None: |
| headers["Content-Type"] = "application/json" |
|
|
| conn = http.client.HTTPSConnection(host, timeout=60) |
|
|
| try: |
| conn.request(method, path, body=body, headers=headers) |
| res = conn.getresponse() |
| raw = res.read().decode("utf-8") |
| finally: |
| conn.close() |
|
|
| try: |
| data = json.loads(raw) if raw else {} |
| except json.JSONDecodeError: |
| data = {"raw": raw} |
|
|
| if res.status >= 400: |
| raise RuntimeError( |
| f"Kie API HTTP {res.status} {res.reason}: " |
| f"{json.dumps(data, ensure_ascii=False)}" |
| ) |
|
|
| if isinstance(data, dict): |
| success = data.get("success") |
|
|
| |
| |
| |
| |
| if success is not None: |
| if not success: |
| raise RuntimeError(f"Kie API error: {json.dumps(data, ensure_ascii=False)}") |
| elif data.get("msg") not in (None, "success"): |
| raise RuntimeError(f"Kie API error: {json.dumps(data, ensure_ascii=False)}") |
|
|
| return data |
|
|
|
|
| KIE_IMAGE_MIN_SIDE = 300 |
| KIE_IMAGE_MAX_SIDE = 4096 |
| KIE_IMAGE_MAX_BYTES = 10 * 1024 * 1024 |
|
|
|
|
| def _get_image_dimensions(image_bytes: bytes, suffix: str) -> tuple: |
| with tempfile.TemporaryDirectory() as tmpdir: |
| path = os.path.join(tmpdir, f"img{suffix}") |
| with open(path, "wb") as f: |
| f.write(image_bytes) |
|
|
| result = subprocess.run( |
| [ |
| "ffprobe", "-v", "error", "-select_streams", "v:0", |
| "-show_entries", "stream=width,height", |
| "-of", "csv=p=0", path, |
| ], |
| check=True, capture_output=True, text=True, |
| ) |
|
|
| w, h = result.stdout.strip().split(",")[:2] |
| return int(w), int(h) |
|
|
|
|
| def _ffmpeg_resize_jpeg(image_bytes: bytes, suffix: str, width: int, height: int, quality: int) -> bytes: |
| with tempfile.TemporaryDirectory() as tmpdir: |
| in_path = os.path.join(tmpdir, f"in{suffix}") |
| out_path = os.path.join(tmpdir, "out.jpg") |
|
|
| with open(in_path, "wb") as f: |
| f.write(image_bytes) |
|
|
|
|
| subprocess.run( |
| ["ffmpeg", "-y", "-i", in_path, "-vf", f"scale={width}:{height}", "-q:v", str(quality), out_path], |
| check=True, capture_output=True, |
| ) |
|
|
| with open(out_path, "rb") as f: |
| return f.read() |
|
|
|
|
| def normalize_image_for_kie(file_bytes: bytes, file_name: str) -> bytes: |
|
|
| suffix = os.path.splitext(file_name)[1].lower() or ".jpg" |
|
|
| try: |
| w, h = _get_image_dimensions(file_bytes, suffix) |
| except Exception: |
| logger.exception("[IMAGE] Could not read dimensions of %s — uploading as-is", file_name) |
| return file_bytes |
|
|
| long_side, short_side = max(w, h), min(w, h) |
| scale = 1.0 |
| if long_side > KIE_IMAGE_MAX_SIDE: |
| scale = KIE_IMAGE_MAX_SIDE / long_side |
| if short_side * scale < KIE_IMAGE_MIN_SIDE: |
| |
| |
| |
| scale = KIE_IMAGE_MIN_SIDE / short_side |
|
|
| |
| new_w = max(2, round(w * scale / 2) * 2) |
| new_h = max(2, round(h * scale / 2) * 2) |
|
|
| already_ok = ( |
| scale == 1.0 |
| and len(file_bytes) <= KIE_IMAGE_MAX_BYTES |
| and suffix in (".jpg", ".jpeg") |
| ) |
| if already_ok: |
| return file_bytes |
|
|
| result = _ffmpeg_resize_jpeg(file_bytes, suffix, new_w, new_h, quality=3) |
|
|
| |
| |
| for quality in (7, 15, 25): |
| if len(result) <= KIE_IMAGE_MAX_BYTES: |
| break |
| result = _ffmpeg_resize_jpeg(file_bytes, suffix, new_w, new_h, quality=quality) |
|
|
| logger.info( |
| "[IMAGE] Normalized %s: %sx%s (%.1fMB) -> %sx%s (%.1fMB)", |
| file_name, w, h, len(file_bytes) / 1e6, new_w, new_h, len(result) / 1e6, |
| ) |
|
|
| return result |
|
|
|
|
| def upload_image_to_kie(file_bytes: bytes, file_name: str, mime_type: str = "image/jpeg") -> str: |
| return upload_file_to_kie(normalize_image_for_kie(file_bytes, file_name), file_name, "image/jpeg") |
|
|
|
|
| def upload_file_to_kie(file_bytes: bytes, file_name: str, mime_type: str = "image/jpeg") -> str: |
| base64_data_uri = f"data:{mime_type};base64,{base64.b64encode(file_bytes).decode('ascii')}" |
|
|
| response = kie_api_request( |
| "POST", |
| KIE_FILE_BASE64_UPLOAD_PATH, |
| payload={ |
| "base64Data": base64_data_uri, |
| "uploadPath": "avatar-ad-video", |
| "fileName": file_name, |
| }, |
| host=KIE_UPLOAD_HOST, |
| ) |
|
|
| download_url = response.get("data", {}).get("downloadUrl") |
|
|
| if not download_url: |
| raise RuntimeError(f"Kie file upload did not return a downloadUrl: {response}") |
|
|
| return download_url |
|
|
|
|
| def create_kie_seedance_task( |
| prompt: str, |
| avatar_image_url: Optional[str] = None, |
| product_image_url: Optional[str] = None, |
| generated_audio_url: Optional[str] = None, |
| negative_prompt: Optional[str] = None, |
| first_frame_url: Optional[str] = None, |
| reference_video_url: Optional[str] = None, |
| duration: int = 15, |
| model: str = "bytedance/seedance-2-5", |
| callback_url: Optional[str] = None, |
| resolution: str = "720p", |
| aspect_ratio: str = "9:16", |
| return_last_frame: bool = False, |
| generate_audio: bool = True, |
| web_search: bool = False, |
| ) -> str: |
|
|
| input_payload: Dict[str, Any] = { |
| "prompt": prompt, |
| "return_last_frame": return_last_frame, |
| "generate_audio": generate_audio, |
| "resolution": resolution, |
| "aspect_ratio": aspect_ratio, |
| "duration": duration, |
| "web_search": web_search, |
| } |
|
|
| |
| |
| if first_frame_url: |
| input_payload["first_frame_url"] = to_public_uri(first_frame_url) |
| else: |
| reference_image_urls = [ |
| to_public_uri(url) for url in as_url_list(avatar_image_url) + as_url_list(product_image_url) |
| ] |
|
|
| if len(reference_image_urls) > 9: |
| logger.warning( |
| "[STEP 3] Kie accepts at most 9 reference images, got %s — truncating to the first 9", |
| len(reference_image_urls), |
| ) |
| reference_image_urls = reference_image_urls[:9] |
|
|
| input_payload["reference_image_urls"] = reference_image_urls |
|
|
| if generated_audio_url: |
| input_payload["reference_audio_urls"] = [ |
| to_public_uri(generated_audio_url) |
| ] |
|
|
|
|
| reference_video_urls = [to_public_uri(url) for url in as_url_list(reference_video_url)][:10] |
| if reference_video_urls: |
| input_payload["reference_video_urls"] = reference_video_urls |
|
|
| if negative_prompt: |
| input_payload["negative_prompt"] = negative_prompt |
|
|
| payload: Dict[str, Any] = { |
| "model": model, |
| "input": input_payload, |
| } |
|
|
| if callback_url: |
| payload["callBackUrl"] = callback_url |
|
|
| logger.info("[STEP 3] Kie Seedance createTask payload: %s", payload) |
|
|
| response = kie_api_request( |
| "POST", |
| KIE_CREATE_TASK_PATH, |
| payload=payload |
| ) |
|
|
| task_id = response.get("data", {}).get("taskId") |
|
|
| if not task_id: |
| raise RuntimeError( |
| f"Kie createTask response did not include taskId: {response}" |
| ) |
|
|
| return task_id |
|
|
|
|
| def get_kie_task_info(task_id: str) -> Dict[str, Any]: |
|
|
| return kie_api_request( |
| "GET", |
| KIE_RECORD_INFO_PATH, |
| query={"taskId": task_id}, |
| ) |
|
|
|
|
| def extract_kie_result_urls(task_info: Dict[str, Any]) -> List[str]: |
|
|
|
|
| data = task_info.get("data", {}) if isinstance(task_info, dict) else {} |
| result_json = data.get("resultJson") |
|
|
| if not result_json: |
| return [] |
|
|
| if isinstance(result_json, str): |
| try: |
| parsed = json.loads(result_json) |
| except json.JSONDecodeError: |
| return [] |
| elif isinstance(result_json, dict): |
| parsed = result_json |
| else: |
| return [] |
|
|
| urls: List[str] = [] |
|
|
| for key in ["resultUrls", "videoUrls", "urls", "outputUrls"]: |
| value = parsed.get(key) |
|
|
| if isinstance(value, list): |
| urls.extend( |
| [item for item in value if isinstance(item, str)] |
| ) |
|
|
| for key in ["url", "videoUrl", "video_url", "output"]: |
| value = parsed.get(key) |
|
|
| if isinstance(value, str): |
| urls.append(value) |
|
|
| return urls |
|
|
|
|
| LAST_FRAME_KEYS = [ |
| "lastFrameUrl", "last_frame_url", "lastFrame", |
| "endFrameUrl", "finalFrameUrl", |
| ] |
|
|
|
|
| def extract_kie_last_frame_url(task_info: Dict[str, Any]) -> Optional[str]: |
| data = task_info.get("data", {}) if isinstance(task_info, dict) else {} |
|
|
| result_json = data.get("resultJson") |
| parsed = {} |
|
|
| if isinstance(result_json, str): |
| try: |
| parsed = json.loads(result_json) |
| except json.JSONDecodeError: |
| parsed = {} |
| elif isinstance(result_json, dict): |
| parsed = result_json |
|
|
| for source in (parsed, data): |
| for key in LAST_FRAME_KEYS: |
| value = source.get(key) |
| if isinstance(value, str) and value: |
| return value |
|
|
| return None |
|
|
|
|
| def wait_for_kie_task( |
| task_id: str, |
| timeout_seconds: int = 900, |
| initial_interval_seconds: float = 3.0, |
| max_interval_seconds: float = 15.0, |
| ) -> Dict[str, Any]: |
|
|
|
|
| deadline = time.time() + timeout_seconds |
| interval = initial_interval_seconds |
|
|
| while time.time() < deadline: |
| task_info = get_kie_task_info(task_id) |
| data = task_info.get("data", {}) |
|
|
| state = data.get("state") or data.get("status") |
| progress = data.get("progress") |
|
|
| logger.info( |
| "[STEP 3] Kie Seedance task %s state=%s progress=%s", |
| task_id, |
| state, |
| progress, |
| ) |
|
|
| if state == "success": |
| result_urls = extract_kie_result_urls(task_info) |
|
|
| return { |
| "task_id": task_id, |
| "state": state, |
| "video_url": result_urls[0] if result_urls else None, |
| "result_urls": result_urls, |
| "raw": task_info, |
| } |
|
|
| if state in {"fail", "failed", "error"}: |
| fail_code = data.get("failCode", "") |
| fail_msg = data.get("failMsg", "") |
|
|
| raise RuntimeError( |
| "Kie Seedance task failed. " |
| f"task_id={task_id}, " |
| f"failCode={fail_code}, " |
| f"failMsg={fail_msg}" |
| ) |
|
|
| time.sleep(interval) |
| interval = min(max_interval_seconds, interval * 1.5) |
|
|
| raise TimeoutError( |
| f"Kie Seedance task timed out after {timeout_seconds}s: {task_id}" |
| ) |
|
|
|
|
| SEEDANCE_MAX_SEGMENT_SECONDS = 15 |
|
|
|
|
| def _get_media_duration_seconds(media_bytes: bytes, suffix: str = ".mp3") -> float: |
| with tempfile.TemporaryDirectory() as tmpdir: |
| media_path = os.path.join(tmpdir, f"media{suffix}") |
| with open(media_path, "wb") as f: |
| f.write(media_bytes) |
|
|
| result = subprocess.run( |
| [ |
| "ffprobe", "-v", "error", "-show_entries", "format=duration", |
| "-of", "default=noprint_wrappers=1:nokey=1", media_path, |
| ], |
| check=True, capture_output=True, text=True, |
| ) |
|
|
| return float(result.stdout.strip()) |
|
|
|
|
| def _get_audio_duration_seconds(audio_bytes: bytes) -> float: |
| return _get_media_duration_seconds(audio_bytes, suffix=".mp3") |
|
|
|
|
| def _trim_audio(audio_bytes: bytes, end_seconds: float) -> bytes: |
| with tempfile.TemporaryDirectory() as tmpdir: |
| in_path = os.path.join(tmpdir, "in.mp3") |
| out_path = os.path.join(tmpdir, "out.mp3") |
|
|
| with open(in_path, "wb") as f: |
| f.write(audio_bytes) |
|
|
| subprocess.run( |
| ["ffmpeg", "-y", "-i", in_path, "-t", str(end_seconds), "-c", "copy", out_path], |
| check=True, capture_output=True, |
| ) |
|
|
| with open(out_path, "rb") as f: |
| return f.read() |
|
|
|
|
| def _split_into_segment_lengths(duration: int, max_segment_seconds: int = SEEDANCE_MAX_SEGMENT_SECONDS) -> List[int]: |
| |
| |
| num_segments = -(-duration // max_segment_seconds) |
| base, remainder = divmod(duration, num_segments) |
|
|
| return [base + (1 if i < remainder else 0) for i in range(num_segments)] |
|
|
|
|
| def _concat_and_mux_segments(video_byte_chunks: List[bytes], audio_bytes: Optional[bytes]) -> bytes: |
| with tempfile.TemporaryDirectory() as tmpdir: |
| segment_paths = [] |
|
|
| for i, chunk in enumerate(video_byte_chunks): |
| path = os.path.join(tmpdir, f"segment_{i}.mp4") |
| with open(path, "wb") as f: |
| f.write(chunk) |
| segment_paths.append(path) |
|
|
| input_args = [] |
| for path in segment_paths: |
| input_args += ["-i", path] |
|
|
| filter_inputs = "".join(f"[{i}:v]" for i in range(len(segment_paths))) |
| filter_complex = f"{filter_inputs}concat=n={len(segment_paths)}:v=1:a=0[outv]" |
|
|
| concatenated_path = os.path.join(tmpdir, "concatenated.mp4") |
| subprocess.run( |
| [ |
| "ffmpeg", "-y", *input_args, |
| "-filter_complex", filter_complex, |
| "-map", "[outv]", |
| "-c:v", "libx264", "-pix_fmt", "yuv420p", |
| concatenated_path, |
| ], |
| check=True, capture_output=True, |
| ) |
|
|
| if not audio_bytes: |
| with open(concatenated_path, "rb") as f: |
| return f.read() |
|
|
| audio_path = os.path.join(tmpdir, "audio.mp3") |
| with open(audio_path, "wb") as f: |
| f.write(audio_bytes) |
|
|
| |
| |
| |
| |
| |
| |
| |
| with open(concatenated_path, "rb") as f: |
| video_duration = _get_media_duration_seconds(f.read(), suffix=".mp4") |
|
|
| final_path = os.path.join(tmpdir, "final.mp4") |
| subprocess.run( |
| [ |
| "ffmpeg", "-y", "-i", concatenated_path, "-i", audio_path, |
| "-c:v", "copy", "-c:a", "aac", "-map", "0:v:0", "-map", "1:a:0", |
| "-t", str(video_duration), |
| final_path, |
| ], |
| check=True, capture_output=True, |
| ) |
|
|
| with open(final_path, "rb") as f: |
| return f.read() |
|
|
|
|
| def apply_logo_watermark(video_url: str, logo_url: str = AMALFA_LOGO_URL) -> str: |
| video_bytes = download_image_bytes(video_url) |
| logo_bytes = download_image_bytes(logo_url) |
|
|
| with tempfile.TemporaryDirectory() as tmpdir: |
| video_path = os.path.join(tmpdir, "in.mp4") |
| logo_path = os.path.join(tmpdir, "logo.jpg") |
| out_path = os.path.join(tmpdir, "out.mp4") |
|
|
| with open(video_path, "wb") as f: |
| f.write(video_bytes) |
| with open(logo_path, "wb") as f: |
| f.write(logo_bytes) |
|
|
| subprocess.run( |
| [ |
| "ffmpeg", "-y", "-i", video_path, "-i", logo_path, |
| "-filter_complex", |
| |
| |
| "[1:v]scale=-1:90[logo];[0:v][logo]overlay=W-w-24:H-h-24[outv]", |
| "-map", "[outv]", "-map", "0:a?", |
| "-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "copy", |
| out_path, |
| ], |
| check=True, capture_output=True, |
| ) |
|
|
| with open(out_path, "rb") as f: |
| watermarked_bytes = f.read() |
|
|
| return upload_file_to_kie(watermarked_bytes, "final_video_watermarked.mp4", "video/mp4") |
|
|
|
|
| def _parse_time_range_seconds(time_range: str) -> Optional[tuple]: |
| numbers = re.findall(r"\d+", time_range or "") |
|
|
| if len(numbers) < 2: |
| return None |
|
|
| return int(numbers[0]), int(numbers[1]) |
|
|
|
|
| def _shots_in_window(shots: List[Dict[str, Any]], window_start: int, window_end: int) -> List[Dict[str, Any]]: |
| matching = [] |
|
|
| for shot in shots: |
| parsed = _parse_time_range_seconds(shot.get("time_range", "")) |
| if not parsed: |
| continue |
|
|
| shot_start, shot_end = parsed |
| if shot_start < window_end and shot_end > window_start: |
| matching.append(shot) |
|
|
| return matching |
|
|
|
|
| def _shots_prompt_block(shots: List[Dict[str, Any]]) -> str: |
| return "\n".join( |
| f'- {shot["title"]}: {shot["description"]} (Camera: {shot["camera"]})' |
| for shot in shots |
| ) |
|
|
|
|
| def generate_seedance_video( |
| generated_script: str, |
| avatar_image_url: str, |
| product_image_url: str, |
| generated_audio_url: str, |
| negative_prompt: Optional[str] = None, |
| reference_video_url: Optional[str] = None, |
| duration: int = 15, |
| model: str = "bytedance/seedance-2-5", |
| callback_url: Optional[str] = None, |
| poll: bool = True, |
| timeout_seconds: int = 1800, |
| resolution: str = "720p", |
| aspect_ratio: str = "9:16", |
| return_last_frame: bool = False, |
| storyboard: Optional[Dict[str, Any]] = None, |
| creative: Optional[Dict[str, Any]] = None, |
| script_model: str = "gpt-5.5", |
| on_storyboard_ready: Optional[Callable[[Dict[str, Any]], None]] = None, |
| ) -> Dict[str, Any]: |
|
|
| logger.info("[STEP 3] Kie Seedance video generation started") |
| logger.info("Avatar image URL: %s", avatar_image_url) |
| logger.info("Product image URL: %s", product_image_url) |
| logger.info("Generated audio URL: %s", generated_audio_url) |
| logger.info("Duration: %s", duration) |
| logger.info("Model: %s", model) |
|
|
| avatar_urls = as_url_list(avatar_image_url) |
| product_urls = as_url_list(product_image_url) |
|
|
| avatar_image_refs = ", ".join(f"[Image{i + 1}]" for i in range(len(avatar_urls))) |
| product_image_refs = ", ".join( |
| f"[Image{i + 1}]" for i in range(len(avatar_urls), len(avatar_urls) + len(product_urls)) |
| ) |
|
|
| |
| |
| |
| reference_video_note = ( |
| "\nUse [Video1] ONLY as a creative concept reference: copy its idea, structure, " |
| "shot progression, pacing, camera movement, framing, transitions, and editing rhythm.\n" |
| "Do NOT take anything else from it. Ignore its people, faces, bodies, clothing, hair, " |
| "voices, products, brands, logos, on-screen text, locations, and background objects. " |
| "None of them may appear in the output.\n" |
| f"The person is always {avatar_image_refs} and the product is always {product_image_refs} — " |
| "never the ones from [Video1].\n" |
| if reference_video_url else "" |
| ) |
|
|
| safe_video_prompt = f""" |
| {generated_script} |
| |
| Use {avatar_image_refs} as the avatar/person reference{'s' if len(avatar_urls) > 1 else ''} (same person, different angles if more than one). |
| Use {product_image_refs} as the product reference{'s' if len(product_urls) > 1 else ''} (same product, different angles if more than one). |
| Use [Audio1] as the generated voiceover timing and pacing reference. |
| {reference_video_note}Keep the avatar face, outfit, and body proportions consistent. |
| Keep the product shape, packaging, color, and branding consistent. |
| Show the product clearly in a realistic, social-media-ready ad style. |
| Do not change the product design, avatar face, or avatar outfit/clothing. |
| """.strip() |
|
|
| segment_lengths = _split_into_segment_lengths(duration) |
| shots = storyboard.get("shots", []) if storyboard else [] |
|
|
| try: |
| if len(segment_lengths) == 1: |
| task_id = create_kie_seedance_task( |
| prompt=safe_video_prompt, |
| avatar_image_url=avatar_image_url, |
| product_image_url=product_image_url, |
| generated_audio_url=generated_audio_url, |
| negative_prompt=negative_prompt, |
| reference_video_url=reference_video_url, |
| duration=duration, |
| model=model, |
| callback_url=callback_url, |
| resolution=resolution, |
| aspect_ratio=aspect_ratio, |
| generate_audio=True, |
| return_last_frame=return_last_frame, |
| ) |
|
|
| logger.info("[STEP 3] Kie Seedance task created: %s", task_id) |
|
|
| if not poll: |
| return { |
| "task_id": task_id, |
| "state": "submitted", |
| "video_url": None, |
| "result_urls": [], |
| } |
|
|
| result = wait_for_kie_task( |
| task_id=task_id, |
| timeout_seconds=timeout_seconds, |
| ) |
|
|
| logger.info("[STEP 3] Kie Seedance video generation completed") |
| logger.info("[STEP 3] Video URL: %s", result.get("video_url")) |
|
|
| return result |
|
|
| logger.info( |
| "[STEP 3] Duration %ss exceeds the %ss single-call max — chaining %s segments", |
| duration, SEEDANCE_MAX_SEGMENT_SECONDS, len(segment_lengths), |
| ) |
|
|
|
|
| shots_per_segment = None |
| combined_storyboard = storyboard |
|
|
| if creative is not None: |
| logger.info("[STEP 3] Generating a storyboard for each of the %s segments", len(segment_lengths)) |
| shots_per_segment = [] |
| all_shots = [] |
| first_segment_storyboard = None |
| seg_start = 0 |
|
|
| for seg_len in segment_lengths: |
| seg_end = seg_start + seg_len |
| seg_storyboard = generate_storyboard( |
| creative=creative, |
| duration_seconds=duration, |
| model=script_model, |
| window_start=seg_start, |
| window_end=seg_end, |
| prior_shots=all_shots, |
| aspect_ratio=aspect_ratio, |
| ) |
|
|
| if first_segment_storyboard is None: |
| first_segment_storyboard = seg_storyboard |
|
|
| for shot in seg_storyboard["shots"]: |
| shot["shot_number"] = len(all_shots) + 1 |
| all_shots.append(shot) |
|
|
| shots_per_segment.append(seg_storyboard["shots"]) |
| seg_start = seg_end |
|
|
| combined_storyboard = { |
| **first_segment_storyboard, |
| "duration_seconds": duration, |
| "shots": all_shots, |
| } |
|
|
| if on_storyboard_ready: |
| on_storyboard_ready(combined_storyboard) |
|
|
| shots = combined_storyboard.get("shots", []) if combined_storyboard else [] |
|
|
| |
| |
| |
| |
| |
| |
| segment1_audio_url = generated_audio_url |
| if generated_audio_url and segment_lengths[0] < duration: |
| trimmed_bytes = _trim_audio(download_image_bytes(generated_audio_url), segment_lengths[0]) |
| segment1_audio_url = upload_file_to_kie(trimmed_bytes, "segment1_audio.mp3", "audio/mpeg") |
|
|
| segment_video_urls = [] |
| video_byte_chunks = [] |
| segment_start = 0 |
|
|
| for i, segment_len in enumerate(segment_lengths): |
| is_last_segment = i == len(segment_lengths) - 1 |
| segment_end = segment_start + segment_len |
| window_shots = ( |
| shots_per_segment[i] if shots_per_segment is not None |
| else _shots_in_window(shots, segment_start, segment_end) |
| ) |
| shots_block = _shots_prompt_block(window_shots) |
|
|
| cta_instruction = ( |
| "\n\nThis is the final segment of the ad — the CTA must appear as on-screen " |
| "text only in the last shot above, ending at the very end of this segment, " |
| "never earlier." |
| if is_last_segment else "" |
| ) |
|
|
| |
| |
| |
| |
| |
| |
| |
| segment_content = ( |
| f"Follow this shot list for this part of the video:\n{shots_block}" |
| if shots_block else generated_script |
| ) |
|
|
| |
| |
| |
| |
| |
| continuity_note = ( |
| "" if i == 0 else |
| "This continues directly from the previous segment — keep the exact same " |
| "avatar, outfit, product, and setting, and continue the ad's action naturally " |
| "without restarting or repeating earlier beats.\n\n" |
| ) |
|
|
| segment_prompt = f""" |
| {continuity_note}{segment_content} |
| |
| Use {avatar_image_refs} as the avatar/person reference{'s' if len(avatar_urls) > 1 else ''} (same person, different angles if more than one). |
| Use {product_image_refs} as the product reference{'s' if len(product_urls) > 1 else ''} (same product, different angles if more than one). |
| Use [Audio1] as the generated voiceover timing and pacing reference. |
| {reference_video_note}Keep the avatar face, outfit, and body proportions consistent. |
| Keep the product shape, packaging, color, and branding consistent. |
| Show the product clearly in a realistic, social-media-ready ad style. |
| Do not change the product design, avatar face, or avatar outfit/clothing. |
| """.strip() + cta_instruction |
|
|
| task_id = create_kie_seedance_task( |
| prompt=segment_prompt, |
| avatar_image_url=avatar_image_url, |
| product_image_url=product_image_url, |
| generated_audio_url=segment1_audio_url if i == 0 else None, |
| negative_prompt=negative_prompt, |
| reference_video_url=reference_video_url, |
| duration=segment_len, |
| model=model, |
| resolution=resolution, |
| aspect_ratio=aspect_ratio, |
| generate_audio=(i == 0), |
| ) |
|
|
| segment_start = segment_end |
|
|
| logger.info("[STEP 3] Segment %s/%s task created: %s", i + 1, len(segment_lengths), task_id) |
|
|
| segment_result = wait_for_kie_task(task_id=task_id, timeout_seconds=timeout_seconds) |
|
|
| if not segment_result.get("video_url"): |
| raise RuntimeError(f"Segment {i + 1} did not return a video_url: {segment_result}") |
|
|
| segment_video_urls.append(segment_result["video_url"]) |
| video_byte_chunks.append(download_image_bytes(segment_result["video_url"])) |
|
|
| logger.info("[STEP 3] All %s segments generated, stitching with ffmpeg", len(segment_lengths)) |
|
|
| audio_bytes = download_image_bytes(generated_audio_url) if generated_audio_url else None |
| final_video_bytes = _concat_and_mux_segments(video_byte_chunks, audio_bytes) |
|
|
| final_video_url = upload_file_to_kie(final_video_bytes, "final_video.mp4", "video/mp4") |
|
|
| logger.info("[STEP 3] Kie Seedance video generation completed (chained)") |
| logger.info("[STEP 3] Video URL: %s", final_video_url) |
|
|
| return { |
| "task_id": None, |
| "state": "success", |
| "video_url": final_video_url, |
| "result_urls": [final_video_url], |
| "segment_video_urls": segment_video_urls, |
| "storyboard": combined_storyboard, |
| "raw": None, |
| } |
|
|
| except Exception: |
| logger.exception("[STEP 3] Kie Seedance video generation failed") |
| raise |
|
|
|
|
| def generate_product_ad_video( |
| product_image_url: str, |
| avatar_image_url: str, |
| voice_public_url: str, |
| angle: str, |
| concept: str, |
| avatar_description: Optional[str] = None, |
| voice_description: Optional[str] = None, |
| duration_seconds: int = 15, |
| script_model: str = "gpt-5.5", |
| aspect_ratio: str = "9:16", |
| manual_script: Optional[str] = None, |
| reference_video_url: Optional[str] = None, |
| on_progress: Optional[Callable[[str], None]] = None, |
| ) -> Dict[str, Any]: |
|
|
| if on_progress is None: |
| on_progress = lambda _message: None |
|
|
| logger.info("Product ad video pipeline started") |
| logger.info("Product image URL: %s", product_image_url) |
| logger.info("Avatar image URL: %s", avatar_image_url) |
| logger.info("Voice public URL: %s", voice_public_url) |
|
|
| try: |
| on_progress("Step 1/5: Writing script and visual direction...") |
|
|
| creative = generate_script_and_visual_direction( |
| product_image_url=product_image_url, |
| angle=angle, |
| concept=concept, |
| avatar_description=avatar_description, |
| voice_description=voice_description or ( |
| "Clone the reference voice and generate a natural performance ad voiceover." |
| ), |
| duration_seconds=duration_seconds, |
| model=script_model, |
| manual_script=manual_script, |
| ) |
|
|
| logger.info("Step 1 completed") |
|
|
| is_chained = duration_seconds > SEEDANCE_MAX_SEGMENT_SECONDS |
|
|
| |
| |
| |
| |
| |
| storyboard = None |
| if not is_chained: |
| storyboard = generate_storyboard( |
| creative=creative, |
| duration_seconds=duration_seconds, |
| model=script_model, |
| aspect_ratio=aspect_ratio, |
| ) |
| logger.info("Step 1B (storyboard) completed") |
|
|
| def _build_poster(sb: Dict[str, Any]) -> Optional[str]: |
| try: |
| return generate_storyboard_poster( |
| storyboard=sb, |
| avatar_image_url=avatar_image_url, |
| product_image_url=product_image_url |
| ) |
| except Exception: |
| logger.exception("Storyboard poster generation failed, continuing without it") |
| return None |
|
|
| with concurrent.futures.ThreadPoolExecutor(max_workers=1) as poster_executor: |
| poster_future_holder: Dict[str, Any] = {} |
|
|
| def _on_storyboard_ready(sb: Dict[str, Any]) -> None: |
| poster_future_holder["future"] = poster_executor.submit(_build_poster, sb) |
|
|
| if storyboard is not None: |
| _on_storyboard_ready(storyboard) |
|
|
| on_progress("Step 2/5: Cloning voice and generating speech...") |
|
|
| audio_result = generate_audio_from_voice_url( |
| voiceover_script=creative["voiceover_script"], |
| voice_public_url=voice_public_url |
| ) |
|
|
| generated_audio_url = audio_result["generated_audio_url"] |
| voice_id = audio_result["voice_id"] |
|
|
| logger.info("Step 2 completed") |
| logger.info("Voice ID: %s", voice_id) |
| logger.info("Generated audio URL: %s", generated_audio_url) |
|
|
| generated_audio_url = ensure_voiceover_fills_duration( |
| creative=creative, |
| generated_audio_url=generated_audio_url, |
| voice_id=voice_id, |
| duration_seconds=duration_seconds, |
| script_model=script_model, |
| allow_rewrite=not manual_script, |
| ) |
|
|
| on_progress("Step 3/5: Generating video (this can take several minutes)...") |
|
|
| video_result = generate_seedance_video( |
| generated_script=creative["seedance_prompt"], |
| avatar_image_url=avatar_image_url, |
| product_image_url=product_image_url, |
| generated_audio_url=generated_audio_url, |
| negative_prompt=creative.get("negative_prompt"), |
| reference_video_url=reference_video_url, |
| duration=duration_seconds, |
| model="bytedance/seedance-2-5", |
| aspect_ratio=aspect_ratio, |
| storyboard=storyboard, |
| creative=creative if is_chained else None, |
| script_model=script_model, |
| on_storyboard_ready=_on_storyboard_ready if is_chained else None, |
| ) |
|
|
| if is_chained: |
| storyboard = video_result.get("storyboard") or {} |
|
|
| logger.info("Step 3 completed") |
|
|
| if video_result.get("video_url"): |
| on_progress("Step 4/5: Adding logo watermark...") |
| video_result["video_url"] = apply_logo_watermark(video_result["video_url"]) |
| logger.info("Logo watermark applied: %s", video_result["video_url"]) |
|
|
| on_progress("Step 5/5: Finalizing storyboard poster...") |
|
|
| poster_future = poster_future_holder.get("future") |
| poster_image_url = poster_future.result() if poster_future else None |
| if poster_image_url: |
| storyboard["poster_image_url"] = poster_image_url |
|
|
| on_progress("Done") |
| logger.info("Product ad video pipeline completed") |
|
|
| return { |
| "creative": creative, |
| "storyboard": storyboard, |
| "voice_id": voice_id, |
| "generated_audio_url": generated_audio_url, |
| "video": video_result |
| } |
|
|
| except Exception: |
| logger.exception("Product ad video pipeline failed") |
| raise |