243 lines
7.0 KiB
Bash
Executable File
243 lines
7.0 KiB
Bash
Executable File
#!/bin/bash
|
|
#
|
|
# generate_story.sh - Generate multi-scene AI videos with I2V chaining
|
|
#
|
|
# Usage: ./generate_story.sh <story_file> <output_dir> [options]
|
|
#
|
|
|
|
set -e
|
|
|
|
# Auto-background: re-exec under nohup if not already backgrounded
|
|
if [ -z "$_MLX_BG" ] && [ -t 0 ]; then
|
|
export _MLX_BG=1
|
|
# Determine log file location (need to peek at args for output dir)
|
|
_LOG_DIR="$HOME/Nextcloud/Documents/mlx-video-stories"
|
|
for _arg in "$@"; do
|
|
if [ -n "$_NEXT_IS_DIR" ]; then _LOG_DIR="$_arg"; unset _NEXT_IS_DIR; break; fi
|
|
[[ "$_arg" == --* ]] && break
|
|
[ "$_SEEN_STORY" = "1" ] && _LOG_DIR="$_arg" && break
|
|
_SEEN_STORY=1
|
|
done
|
|
mkdir -p "$_LOG_DIR"
|
|
_LOG="$_LOG_DIR/generation.log"
|
|
echo "Running in background. Log: $_LOG"
|
|
echo "Follow with: tail -f $_LOG"
|
|
nohup "$0" "$@" > "$_LOG" 2>&1 &
|
|
echo "PID: $!"
|
|
exit 0
|
|
fi
|
|
|
|
# Default settings
|
|
WIDTH=1280
|
|
HEIGHT=768
|
|
FRAMES=121
|
|
STRENGTH=0.7
|
|
FPS=24
|
|
PIPELINE="dev-two-stage-hq"
|
|
MODEL_REPO="prince-canuma/LTX-2.3-dev"
|
|
VENV_PYTHON="${VENV_PYTHON:-./venv/bin/python}"
|
|
OUTPUT_DIR="$HOME/Nextcloud/Documents/mlx-video-stories"
|
|
|
|
# Colors
|
|
RED='\033[0;31m'
|
|
GREEN='\033[0;32m'
|
|
YELLOW='\033[0;33m'
|
|
BLUE='\033[0;34m'
|
|
NC='\033[0m'
|
|
|
|
usage() {
|
|
|
|
echo "Usage: $0 <story_file> [output_dir] [options]"
|
|
echo ""
|
|
echo "Arguments:"
|
|
echo " story_file Text file with one prompt per line"
|
|
echo " output_dir Directory to save output files (default: ./output)"
|
|
echo ""
|
|
echo "Options:"
|
|
echo " --width Video width (default: 1920)"
|
|
echo " --height Video height (default: 1088)"
|
|
echo " --frames Frames per scene (default: 121)"
|
|
echo " --strength I2V conditioning strength 0.0-1.0 (default: 0.7)"
|
|
echo " --fps Output framerate (default: 24)"
|
|
echo " --python Python executable (default: python)"
|
|
echo ""
|
|
echo "Example:"
|
|
echo " $0 stories/mountain.txt output/ --width 1024 --height 768"
|
|
exit 1
|
|
}
|
|
|
|
# Parse arguments
|
|
if [ $# -lt 1 ]; then
|
|
usage
|
|
fi
|
|
|
|
STORY_FILE="$1"
|
|
shift 1
|
|
|
|
# Check if second arg is output dir (not an option starting with --)
|
|
if [ $# -gt 0 ] && [[ "$1" != --* ]]; then
|
|
OUTPUT_DIR="$1"
|
|
shift 1
|
|
fi
|
|
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--width) WIDTH="$2"; shift 2 ;;
|
|
--height) HEIGHT="$2"; shift 2 ;;
|
|
--frames) FRAMES="$2"; shift 2 ;;
|
|
--strength) STRENGTH="$2"; shift 2 ;;
|
|
--fps) FPS="$2"; shift 2 ;;
|
|
--pipeline) PIPELINE="$2"; shift 2 ;;
|
|
--model-repo) MODEL_REPO="$2"; shift 2 ;;
|
|
--python) VENV_PYTHON="$2"; shift 2 ;;
|
|
*) echo "Unknown option: $1"; usage ;;
|
|
esac
|
|
done
|
|
|
|
# Validate inputs
|
|
if [ ! -f "$STORY_FILE" ]; then
|
|
echo -e "${RED}Error: Story file not found: $STORY_FILE${NC}"
|
|
exit 1
|
|
fi
|
|
|
|
# Create output directory
|
|
mkdir -p "$OUTPUT_DIR"
|
|
|
|
# Read prompts into array (compatible with bash 3, skip comments and empty lines)
|
|
PROMPTS=()
|
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
|
# Skip empty lines and comments
|
|
[[ -z "$line" ]] && continue
|
|
[[ "$line" =~ ^[[:space:]]*$ ]] && continue
|
|
[[ "$line" =~ ^[[:space:]]*# ]] && continue
|
|
PROMPTS+=("$line")
|
|
done < "$STORY_FILE"
|
|
NUM_SCENES=${#PROMPTS[@]}
|
|
|
|
if [ $NUM_SCENES -eq 0 ]; then
|
|
echo -e "${RED}Error: No prompts found in $STORY_FILE${NC}"
|
|
exit 1
|
|
fi
|
|
|
|
# Get story name from filename
|
|
STORY_NAME=$(basename "$STORY_FILE" .txt)
|
|
|
|
echo -e "${BLUE}========================================${NC}"
|
|
echo -e "${BLUE} mlx-video-maker${NC}"
|
|
echo -e "${BLUE}========================================${NC}"
|
|
echo ""
|
|
echo -e "Story: ${GREEN}$STORY_NAME${NC}"
|
|
echo -e "Scenes: ${GREEN}$NUM_SCENES${NC}"
|
|
echo -e "Resolution: ${GREEN}${WIDTH}x${HEIGHT}${NC}"
|
|
echo -e "Frames/scene: ${GREEN}$FRAMES${NC}"
|
|
echo -e "I2V strength: ${GREEN}$STRENGTH${NC}"
|
|
echo -e "Output: ${GREEN}$OUTPUT_DIR${NC}"
|
|
echo ""
|
|
|
|
# Resolve VENV_PYTHON to absolute path before changing directories
|
|
if [[ "$VENV_PYTHON" == ./* ]]; then
|
|
VENV_PYTHON="$(pwd)/${VENV_PYTHON:2}"
|
|
fi
|
|
|
|
# Generate scenes
|
|
cd "$OUTPUT_DIR"
|
|
|
|
for i in $(seq 1 $NUM_SCENES); do
|
|
IDX=$((i-1))
|
|
PROMPT="${PROMPTS[$IDX]}"
|
|
SCENE_FILE="scene${i}.mp4"
|
|
|
|
# Skip if already exists
|
|
if [ -f "$SCENE_FILE" ]; then
|
|
echo -e "${YELLOW}Scene $i already exists, skipping...${NC}"
|
|
continue
|
|
fi
|
|
|
|
echo ""
|
|
echo -e "${BLUE}=== Scene $i / $NUM_SCENES ===${NC}"
|
|
echo -e "${GREEN}Prompt:${NC} ${PROMPT:0:80}..."
|
|
echo ""
|
|
|
|
if [ $i -eq 1 ]; then
|
|
# First scene: Text-to-Video
|
|
$VENV_PYTHON -m mlx_video.models.ltx_2.generate \
|
|
--prompt "$PROMPT" \
|
|
--pipeline $PIPELINE \
|
|
--model-repo $MODEL_REPO \
|
|
--text-encoder-repo google/gemma-3-12b-it \
|
|
--height $HEIGHT \
|
|
--width $WIDTH \
|
|
--num-frames $FRAMES \
|
|
--fps $FPS \
|
|
--seed $((42 + i)) \
|
|
--audio \
|
|
--output-path "$SCENE_FILE"
|
|
else
|
|
# Subsequent scenes: Image-to-Video
|
|
PREV=$((i-1))
|
|
PREV_FILE="scene${PREV}.mp4"
|
|
LAST_FRAME="scene${PREV}_lastframe.jpg"
|
|
|
|
# Extract last frame from previous scene
|
|
if [ ! -f "$LAST_FRAME" ]; then
|
|
echo -e "${YELLOW}Extracting last frame from scene $PREV...${NC}"
|
|
FRAME_COUNT=$(ffprobe -v error -select_streams v:0 -count_frames \
|
|
-show_entries stream=nb_read_frames \
|
|
-of default=nokey=1:noprint_wrappers=1 "$PREV_FILE")
|
|
LAST_IDX=$((FRAME_COUNT - 1))
|
|
ffmpeg -i "$PREV_FILE" -vf "select=eq(n\\,$LAST_IDX)" \
|
|
-vframes 1 -q:v 2 "$LAST_FRAME" -y 2>/dev/null
|
|
fi
|
|
|
|
# Generate with I2V
|
|
$VENV_PYTHON -m mlx_video.models.ltx_2.generate \
|
|
--prompt "$PROMPT" \
|
|
--pipeline $PIPELINE \
|
|
--model-repo $MODEL_REPO \
|
|
--text-encoder-repo google/gemma-3-12b-it \
|
|
--image "$LAST_FRAME" \
|
|
--image-strength $STRENGTH \
|
|
--height $HEIGHT \
|
|
--width $WIDTH \
|
|
--num-frames $FRAMES \
|
|
--fps $FPS \
|
|
--seed $((42 + i)) \
|
|
--audio \
|
|
--output-path "$SCENE_FILE"
|
|
fi
|
|
|
|
echo -e "${GREEN}Scene $i complete!${NC}"
|
|
done
|
|
|
|
echo ""
|
|
echo -e "${BLUE}=== Concatenating final movie ===${NC}"
|
|
|
|
# Create concat list
|
|
CONCAT_LIST="concat_list.txt"
|
|
> "$CONCAT_LIST"
|
|
for i in $(seq 1 $NUM_SCENES); do
|
|
if [ -f "scene${i}.mp4" ]; then
|
|
echo "file 'scene${i}.mp4'" >> "$CONCAT_LIST"
|
|
fi
|
|
done
|
|
|
|
# Concatenate with high quality encoding
|
|
FINAL_FILE="${STORY_NAME}.mp4"
|
|
ffmpeg -f concat -safe 0 -i "$CONCAT_LIST" \
|
|
-c:v libx264 -crf 18 -preset slow \
|
|
-c:a aac -b:a 192k \
|
|
"$FINAL_FILE" -y
|
|
|
|
# Get duration
|
|
DURATION=$(ffprobe -v error -show_entries format=duration \
|
|
-of default=noprint_wrappers=1:nokey=1 "$FINAL_FILE")
|
|
|
|
echo ""
|
|
echo -e "${GREEN}========================================${NC}"
|
|
echo -e "${GREEN} Complete!${NC}"
|
|
echo -e "${GREEN}========================================${NC}"
|
|
echo ""
|
|
echo -e "Final movie: ${BLUE}$(pwd)/$FINAL_FILE${NC}"
|
|
echo -e "Duration: ${BLUE}${DURATION%.*} seconds${NC}"
|
|
echo ""
|