File size: 9,736 Bytes
287c9ca
 
 
 
 
 
50c620f
287c9ca
 
50c620f
 
 
 
 
 
 
 
287c9ca
50c620f
 
287c9ca
50c620f
 
 
b97795f
50c620f
 
b97795f
 
 
 
 
 
 
50c620f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b97795f
 
50c620f
b97795f
287c9ca
 
50c620f
b97795f
 
 
 
287c9ca
50c620f
 
 
 
287c9ca
 
b97795f
50c620f
 
 
b97795f
50c620f
 
 
 
 
 
 
 
 
b97795f
50c620f
 
 
 
 
 
 
 
 
 
 
 
 
 
b97795f
 
 
50c620f
 
 
 
 
287c9ca
50c620f
b97795f
50c620f
b97795f
287c9ca
b97795f
50c620f
 
 
b97795f
 
 
 
50c620f
b97795f
 
 
287c9ca
 
b97795f
 
50c620f
b97795f
 
 
287c9ca
 
b97795f
287c9ca
 
 
 
 
b97795f
 
50c620f
287c9ca
b97795f
50c620f
b97795f
 
50c620f
 
 
 
 
 
 
 
b97795f
50c620f
b97795f
 
 
 
287c9ca
50c620f
b97795f
 
 
50c620f
 
 
 
b97795f
50c620f
b97795f
50c620f
 
 
 
 
 
b97795f
 
 
 
50c620f
 
 
b97795f
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
# core/visual_engine.py
from PIL import Image, ImageDraw, ImageFont
from moviepy.editor import ImageClip, concatenate_videoclips
import os

class VisualEngine:
    def __init__(self, output_dir="temp_generated_media"): # Changed default to avoid confusion with local dev
        self.output_dir = output_dir
        os.makedirs(self.output_dir, exist_ok=True)
        
        # --- Font Setup ---
        # This path should match where you COPY the font in your Dockerfile.
        # Example: COPY assets/fonts/arial.ttf /usr/local/share/fonts/truetype/mycustomfonts/arial.ttf
        self.font_filename = "arial.ttf" # Or "DejaVuSans.ttf" or your chosen font
        self.font_path_in_container = f"/usr/local/share/fonts/truetype/mycustomfonts/{self.font_filename}"
        self.font_size_pil = 24
        
        try:
            self.font = ImageFont.truetype(self.font_path_in_container, self.font_size_pil)
            print(f"Successfully loaded font: {self.font_path_in_container} with size {self.font_size_pil}")
        except IOError:
            print(f"Error: Could not load font from '{self.font_path_in_container}'. "
                  f"Ensure the font file '{self.font_filename}' is in 'assets/fonts/' "
                  f"and the Dockerfile correctly copies it. Falling back to default font.")
            self.font = ImageFont.load_default()
            # Adjust size if using default font, as it's typically smaller and metrics differ
            self.font_size_pil = 11 # Default font is often ~10px, this is an estimate for line height


    def _get_text_dimensions(self, text_content, font_obj):
        """
        Gets the width and height of a single line of text with the given font.
        Returns (width, height).
        """
        if text_content == "" or text_content is None: # Handle empty string
            return 0, self.font_size_pil # Return 0 width, default height

        try:
            if hasattr(font_obj, 'getbbox'): # For newer Pillow versions (>=8.0.0)
                # getbbox returns (left, top, right, bottom)
                bbox = font_obj.getbbox(text_content)
                width = bbox[2] - bbox[0]
                height = bbox[3] - bbox[1]
                # If height is 0 for some reason (e.g. empty string was passed and not caught), use font_size_pil
                return width, height if height > 0 else self.font_size_pil
            elif hasattr(font_obj, 'getsize'): # For older Pillow versions
                width, height = font_obj.getsize(text_content)
                return width, height if height > 0 else self.font_size_pil
            else: # Fallback for very basic font objects (like default font after failure)
                avg_char_width = self.font_size_pil * 0.6 # Rough estimate
                height_estimate = self.font_size_pil * 1.2
                return int(len(text_content) * avg_char_width), int(height_estimate if height_estimate > 0 else self.font_size_pil)
        except Exception as e:
            print(f"Warning: Error getting text dimensions for '{text_content}': {e}. Using estimates.")
            # Fallback estimates if any error occurs
            avg_char_width = self.font_size_pil * 0.6
            height_estimate = self.font_size_pil * 1.2
            return int(len(text_content) * avg_char_width), int(height_estimate if height_estimate > 0 else self.font_size_pil)


    def create_placeholder_image(self, text_description, filename, size=(1280, 720)):
        img = Image.new('RGB', size, color=(30, 30, 60))  # Darker blueish background
        draw = ImageDraw.Draw(img)

        padding = 40
        max_text_width = size[0] - (2 * padding)
        lines = []
        
        if not text_description: # Handle case where text_description might be None or empty
            text_description = "(No description provided)"

        words = text_description.split()
        current_line = ""

        if not self.font: # Should not happen if __init__ fallback works, but as a safeguard
            print("Error: Font object is not initialized in create_placeholder_image. Cannot draw text.")
            return None

        for word in words:
            test_line_candidate = current_line + word + " "
            line_width, _ = self._get_text_dimensions(test_line_candidate.strip(), self.font)

            if line_width <= max_text_width and current_line != "": # If it fits and current_line is not empty
                current_line = test_line_candidate
            elif line_width <= max_text_width and current_line == "": # First word, and it fits
                 current_line = test_line_candidate
            elif current_line != "": # If it doesn't fit and current_line is not empty, finalize current_line
                lines.append(current_line.strip())
                current_line = word + " " # Start new line with current word
            else: # Word itself is too long for a line
                # Simple truncation for very long words (can be improved with character-level wrapping)
                temp_word = word
                while self._get_text_dimensions(temp_word, self.font)[0] > max_text_width and len(temp_word) > 0:
                    temp_word = temp_word[:-1]
                lines.append(temp_word)
                current_line = "" # Reset current line

        if current_line.strip(): # Add the last line if it has content
            lines.append(current_line.strip())
        
        if not lines: # If after all that, lines is empty (e.g. only very long words that got truncated to nothing)
            lines.append("(Text too long or error)")

        # Calculate starting y position to center the text block
        _, single_line_height = self._get_text_dimensions("Tg", self.font) # Get height of a typical line
        if single_line_height == 0: # Safety for failed get_text_dimensions
            single_line_height = self.font_size_pil 
            
        line_spacing_factor = 1.3 # Adjust for spacing between lines
        estimated_line_block_height = len(lines) * single_line_height * line_spacing_factor
        
        y_text = (size[1] - estimated_line_block_height) / 2.0
        if y_text < padding: # Ensure text doesn't start too high if too much text
            y_text = float(padding)

        for line in lines:
            line_width, _ = self._get_text_dimensions(line, self.font)
            x_text = (size[0] - line_width) / 2.0
            if x_text < padding: # Ensure text doesn't start too left
                x_text = float(padding)

            draw.text(
                xy=(x_text, y_text),
                text=line,
                fill=(220, 220, 150),  # Lighter Yellow text
                font=self.font
            )
            y_text += single_line_height * line_spacing_factor

        filepath = os.path.join(self.output_dir, filename)
        try:
            img.save(filepath)
            # print(f"Placeholder image saved: {filepath}") # Can be noisy, uncomment for debugging
        except Exception as e:
            print(f"Error saving image {filepath}: {e}")
            return None
        return filepath


    def create_video_from_images(self, image_paths, output_filename="final_video.mp4", fps=1, duration_per_image=3):
        if not image_paths:
            print("No images provided to create video.")
            return None

        valid_image_paths = [p for p in image_paths if p and os.path.exists(p)]
        if not valid_image_paths:
            print("No valid image paths found to create video. Ensure images were generated and paths are correct.")
            return None
        
        print(f"Attempting to create video from {len(valid_image_paths)} images: {valid_image_paths}")

        try:
            clips = []
            for m_path in valid_image_paths:
                try:
                    clip = ImageClip(m_path).set_duration(duration_per_image)
                    clips.append(clip)
                except Exception as e_clip:
                    print(f"Error creating ImageClip for {m_path}: {e_clip}. Skipping this image.")
            
            if not clips:
                print("Could not create any ImageClips from the provided image paths.")
                return None
                
            video_clip = concatenate_videoclips(clips, method="compose")
            output_path = os.path.join(self.output_dir, output_filename)
            
            print(f"Writing video to: {output_path}")
            video_clip.write_videofile(
                output_path,
                fps=fps,
                codec='libx264',
                audio_codec='aac', # Required even if no audio, or can cause issues
                temp_audiofile=os.path.join(self.output_dir, 'temp-audio.m4a'), # Ensure this path is writable
                remove_temp=True,
                threads=os.cpu_count() or 2, # Use available CPUs or default to 2
                logger='bar' # Use 'bar' for progress, None to suppress totally
            )
            # Close clips to release file handles, especially important on some OS / Docker
            for clip_to_close in clips:
                clip_to_close.close()
            if hasattr(video_clip, 'close'):
                video_clip.close()

            print(f"Video successfully created: {output_path}")
            return output_path
        except Exception as e:
            print(f"Error creating video: {e}")
            if isinstance(e, OSError): # Or check for specific ffmpeg error messages
                print("OSError during video creation. This often indicates an issue with ffmpeg, "
                      "its installation, or permissions to write temporary files.")
            return None