import os
import numpy as np
import requests
from contextlib import contextmanager
from manim import *
import hashlib
import math
from moviepy import AudioFileClip
MY_STONE_COLOR = "#A9A9A9"
MY_KEYSTONE_COLOR = "#D2B48C"
MY_GROUND_COLOR = "#8B4513"
MY_SKY_COLOR = "#ADD8E6"
MY_FORCE_GRAVITY = RED_E
MY_FORCE_COMPRESSION = BLUE_D
MY_FORCE_THRUST = ORANGE
MY_TEXT_COLOR = BLACK
MY_WHITE = "#FFFFFF"
MY_BLACK = "#000000"
MY_HIGHLIGHT_COLOR = YELLOW_D
CACHE_DIR = "tts_cache"
os.makedirs(CACHE_DIR, exist_ok=True)
class CustomVoiceoverTracker:
"""Tracks audio path and duration for TTS."""
def __init__(self, audio_path, duration):
self.audio_path = audio_path
self.duration = duration
def get_cache_filename(text):
"""Generates a unique filename based on the text hash."""
text_hash = hashlib.md5(text.encode('utf-8')).hexdigest()
return os.path.join(CACHE_DIR, f"{text_hash}.mp3")
@contextmanager
def custom_voiceover_tts(text, token="123456", base_url="https://uni-ai.fly.dev/api/manim/tts"):
"""
Fetches TTS audio, caches it, and provides path and duration.
Usage: with custom_voiceover_tts("text") as tracker: ...
"""
cache_file = get_cache_filename(text)
audio_file = cache_file
if os.path.exists(cache_file):
audio_file = cache_file
print(f"Using cached TTS for: {text[:30]}...")
else:
print(f"Requesting TTS for: {text[:30]}...")
try:
input_text_encoded = requests.utils.quote(text)
url = f"{base_url}?token={token}&input={input_text_encoded}"
response = requests.get(url, stream=True, timeout=60)
response.raise_for_status()
with open(cache_file, "wb") as f:
for chunk in response.iter_content(chunk_size=8192):
if chunk:
f.write(chunk)
audio_file = cache_file
print("TTS downloaded and cached.")
except requests.exceptions.RequestException as e:
print(f"TTS API request failed: {e}")
tracker = CustomVoiceoverTracker(None, 0)
yield tracker
return
if audio_file and os.path.exists(audio_file):
try:
clip = AudioFileClip(audio_file)
duration = clip.duration
clip.close()
print(f"Audio duration: {duration:.2f}s")
tracker = CustomVoiceoverTracker(audio_file, duration)
except Exception as e:
print(f"Error processing audio file {audio_file}: {e}")
tracker = CustomVoiceoverTracker(None, 0)
else:
print(f"TTS audio file not found or not created: {audio_file}")
tracker = CustomVoiceoverTracker(None, 0)
try:
yield tracker
finally:
pass
def create_voussoir(center, inner_radius, outer_radius, start_angle, end_angle, color=MY_STONE_COLOR):
"""Creates a single wedge-shaped voussoir polygon."""
points = []
num_segments = 5
for i in range(num_segments + 1):
angle = start_angle + (end_angle - start_angle) * i / num_segments
points.append(center + outer_radius * np.array([np.cos(angle), np.sin(angle), 0]))
for i in range(num_segments + 1):
angle = end_angle - (end_angle - start_angle) * i / num_segments
points.append(center + inner_radius * np.array([np.cos(angle), np.sin(angle), 0]))
points.append(points[0])
return Polygon(*points, color=color, fill_opacity=1.0, stroke_color=MY_BLACK, stroke_width=1.5)
def create_keystone(center, inner_radius, outer_radius, start_angle, end_angle, top_angle_factor=1.1, bottom_angle_factor=0.9, color=MY_KEYSTONE_COLOR):
"""Creates the keystone polygon, slightly wider at the top."""
mid_angle = (start_angle + end_angle) / 2
half_angle_width = (end_angle - start_angle) / 2
top_start_angle = mid_angle - half_angle_width * top_angle_factor
top_end_angle = mid_angle + half_angle_width * top_angle_factor
bottom_start_angle = mid_angle - half_angle_width * bottom_angle_factor
bottom_end_angle = mid_angle + half_angle_width * bottom_angle_factor
points = []
num_segments = 5
for i in range(num_segments + 1):
angle = top_start_angle + (top_end_angle - top_start_angle) * i / num_segments
points.append(center + outer_radius * np.array([np.cos(angle), np.sin(angle), 0]))
points.append(center + inner_radius * np.array([np.cos(bottom_end_angle), np.sin(bottom_end_angle), 0]))
for i in range(num_segments + 1):
angle = bottom_end_angle - (bottom_end_angle - bottom_start_angle) * i / num_segments
points.append(center + inner_radius * np.array([np.cos(angle), np.sin(angle), 0]))
points.append(center + inner_radius * np.array([np.cos(bottom_start_angle), np.sin(bottom_start_angle), 0]))
points.append(points[0])
return Polygon(*points, color=color, fill_opacity=1.0, stroke_color=MY_BLACK, stroke_width=1.5)
class CombinedScene(MovingCameraScene):
"""
Manim animation explaining the function of a keystone in an arch.
"""
def construct(self):
self.scene_time_tracker = ValueTracker(0)
self.play_scene_01()
self.clear_and_reset()
self.play_scene_02()
self.play_scene_03()
self.play_scene_04()
self.play_scene_05()
self.play_scene_06()
self.clear_and_reset()
final_message = Text("Animation Complete! 😄", font_size=48, color=MY_WHITE)
bg_final = Rectangle(width=config.frame_width, height=config.frame_height, fill_color=MY_BLACK, fill_opacity=1,
stroke_width=0).set_z_index(-10)
self.add(bg_final)
self.play(FadeIn(final_message))
self.wait(2)
def get_scene_number(self, number_str):
"""Creates and positions the scene number."""
scene_num = Text(number_str, font_size=24, color=MY_BLACK)
scene_num.to_corner(UR, buff=0.3)
scene_num.set_z_index(10)
return scene_num
def clear_and_reset(self):
"""Clears current scene objects and resets camera."""
all_mobs_to_clear = list(self.mobjects)
if hasattr(self.camera, 'fixed_in_frame_mobjects'):
all_mobs_to_clear += list(self.camera.fixed_in_frame_mobjects)
for mob in all_mobs_to_clear:
if mob is not None and hasattr(mob, 'get_updaters') and mob.get_updaters():
mob.clear_updaters()
valid_mobjects = [m for m in all_mobs_to_clear if m is not None]
all_mobjects_group = Group(*valid_mobjects)
if all_mobjects_group:
self.play(FadeOut(all_mobjects_group, shift=DOWN * 0.5), run_time=0.5)
self.mobjects.clear()
if hasattr(self.camera, 'fixed_in_frame_mobjects'):
self.camera.fixed_in_frame_mobjects.clear()
self.camera.frame.move_to(ORIGIN)
self.camera.frame.set(width=config.frame_width, height=config.frame_height)
self.scene_time_tracker.set_value(0)
self.wait(0.1)
def play_scene_01(self):
"""Scene 1: Title and Introduction"""
self.scene_time_tracker.set_value(0)
bg1 = Rectangle(
width=config.frame_width, height=config.frame_height,
fill_color=MY_SKY_COLOR, fill_opacity=1.0, stroke_width=0
).set_z_index(-10)
self.add(bg1)
scene_num_01 = self.get_scene_number("01")
self.add(scene_num_01)
title = Text("The Magic of the Keystone Arch ✨", font_size=60, color=MY_BLACK)
title.move_to(ORIGIN)
voice_text_01 = "Hello! Today we'll explore how a simple stone arch stays standing, focusing on the crucial role of the keystone."
with custom_voiceover_tts(voice_text_01) as tracker:
if tracker.audio_path and tracker.duration > 0:
self.add_sound(tracker.audio_path, time_offset=0)
else:
print("Warning: Scene 1 TTS audio failed or has zero duration.")
subtitle_voice = Text(
voice_text_01, font_size=32, color=MY_BLACK,
width=config.frame_width - 2, should_center=True
).to_edge(DOWN, buff=0.5)
anim_runtime_title = 2.0
fade_out_duration = 1.0
total_anim_duration_planned = anim_runtime_title
self.play(
AnimationGroup(
FadeIn(subtitle_voice, run_time=0.5),
Write(title, run_time=anim_runtime_title),
lag_ratio=0.0
),
run_time=anim_runtime_title
)
if tracker.duration > 0:
elapsed_time = total_anim_duration_planned
time_for_fadeout = fade_out_duration
remaining_time = tracker.duration - elapsed_time - time_for_fadeout
if remaining_time > 0:
self.wait(remaining_time)
else:
self.wait(1.0)
self.play(FadeOut(subtitle_voice), run_time=fade_out_duration)
self.wait(0.5)
def play_scene_02(self):
"""Scene 2: Building up voussoirs, gravity, the gap."""
self.scene_time_tracker.set_value(0)
bg2 = Rectangle(width=config.frame_width, height=config.frame_height, fill_color=MY_SKY_COLOR, fill_opacity=1.0, stroke_width=0).set_z_index(-10)
self.add(bg2)
scene_num_02 = self.get_scene_number("02")
self.add(scene_num_02)
arch_center = DOWN * 1.5
inner_radius = 2.5
outer_radius = 3.5
arch_angle_span = 150 * DEGREES
start_angle = (90 - arch_angle_span / 2) * DEGREES
end_angle = (90 + arch_angle_span / 2) * DEGREES
num_voussoirs_per_side = 5
total_voussoirs = 2 * num_voussoirs_per_side
voussoir_angle = arch_angle_span / (total_voussoirs + 1)
abutment_width = 1.5
abutment_height = 3
abutment_left_pos = arch_center + inner_radius * np.array([np.cos(end_angle), np.sin(end_angle), 0]) + LEFT * abutment_width / 2 + DOWN * abutment_height / 2
abutment_right_pos = arch_center + inner_radius * np.array([np.cos(start_angle), np.sin(start_angle), 0]) + RIGHT * abutment_width / 2 + DOWN * abutment_height / 2
abutment_left = Rectangle(width=abutment_width, height=abutment_height, color=MY_GROUND_COLOR, fill_opacity=1.0).move_to(abutment_left_pos)
abutment_right = Rectangle(width=abutment_width, height=abutment_height, color=MY_GROUND_COLOR, fill_opacity=1.0).move_to(abutment_right_pos)
self.play(Create(abutment_left), Create(abutment_right))
self.wait(0.5)
left_voussoirs = VGroup()
right_voussoirs = VGroup()
voussoir_list_anim = []
for i in range(num_voussoirs_per_side):
v_start = start_angle + i * voussoir_angle
v_end = v_start + voussoir_angle
voussoir_r = create_voussoir(arch_center, inner_radius, outer_radius, v_start, v_end)
right_voussoirs.add(voussoir_r)
voussoir_list_anim.append(Create(voussoir_r))
v_end_l = end_angle - i * voussoir_angle
v_start_l = v_end_l - voussoir_angle
voussoir_l = create_voussoir(arch_center, inner_radius, outer_radius, v_start_l, v_end_l)
left_voussoirs.add(voussoir_l)
voussoir_list_anim.append(Create(voussoir_l))
self.abutments = VGroup(abutment_left, abutment_right)
self.left_voussoirs = left_voussoirs
self.right_voussoirs = right_voussoirs
self.arch_center = arch_center
self.inner_radius = inner_radius
self.outer_radius = outer_radius
self.voussoir_angle = voussoir_angle
self.num_voussoirs_per_side = num_voussoirs_per_side
gravity_arrows = VGroup()
if num_voussoirs_per_side >= 2:
arrow1 = Arrow(start=right_voussoirs[1].get_center_of_mass() + UP*0.1, end=right_voussoirs[1].get_center_of_mass() + DOWN * 0.8, buff=0.1, color=MY_FORCE_GRAVITY)
arrow2 = Arrow(start=left_voussoirs[1].get_center_of_mass() + UP*0.1, end=left_voussoirs[1].get_center_of_mass() + DOWN * 0.8, buff=0.1, color=MY_FORCE_GRAVITY)
gravity_arrows.add(arrow1, arrow2)
voice_text_02 = "Imagine building an arch. You start placing wedge-shaped stones, called voussoirs, from both sides on temporary supports. Each stone leans inwards. Gravity pulls them down, creating a gap at the very top."
with custom_voiceover_tts(voice_text_02) as tracker:
if tracker.audio_path and tracker.duration > 0:
self.add_sound(tracker.audio_path, time_offset=0)
else:
print("Warning: Scene 2 TTS audio failed or has zero duration.")
subtitle_voice = Text(
voice_text_02, font_size=32, color=MY_BLACK,
width=config.frame_width - 2, should_center=True
).to_edge(DOWN, buff=0.5)
anim_build_time = 3.0
anim_gravity_time = 1.5
fade_out_duration = 1.0
total_anim_duration_planned = anim_build_time + anim_gravity_time
self.play(FadeIn(subtitle_voice, run_time=0.5))
self.play(AnimationGroup(*voussoir_list_anim, lag_ratio=0.15), run_time=anim_build_time)
self.play(AnimationGroup(*[Create(arrow) for arrow in gravity_arrows]), run_time=anim_gravity_time)
if tracker.duration > 0:
elapsed_time = 0.5 + total_anim_duration_planned
time_for_fadeout = fade_out_duration
remaining_time = tracker.duration - elapsed_time - time_for_fadeout
if remaining_time > 0:
self.wait(remaining_time)
else:
self.wait(1.0)
self.play(FadeOut(subtitle_voice), FadeOut(gravity_arrows), run_time=fade_out_duration)
self.wait(0.5)
def play_scene_03(self):
"""Scene 3: Show the keystone and its shape."""
self.scene_time_tracker.set_value(0)
scene_num_03 = self.get_scene_number("03")
self.add(scene_num_03)
keystone_start_angle = self.arch_center[1] + self.num_voussoirs_per_side * self.voussoir_angle
keystone_end_angle = keystone_start_angle + self.voussoir_angle
arch_angle_span = 150 * DEGREES
center_angle = 90 * DEGREES
keystone_start_angle = center_angle - self.voussoir_angle / 2
keystone_end_angle = center_angle + self.voussoir_angle / 2
keystone_obj = create_keystone(
self.arch_center, self.inner_radius, self.outer_radius,
keystone_start_angle, keystone_end_angle,
color=MY_KEYSTONE_COLOR
)
keystone_obj.shift(UP * 1.5)
keystone_label = Text("Keystone", font_size=36, color=MY_BLACK)
keystone_label.next_to(keystone_obj, UP, buff=0.3)
self.keystone = keystone_obj
voice_text_03 = "This top gap is filled by a special stone: the keystone. Notice its wedge shape - wider at the top, tapering downwards."
with custom_voiceover_tts(voice_text_03) as tracker:
if tracker.audio_path and tracker.duration > 0:
self.add_sound(tracker.audio_path, time_offset=0)
else:
print("Warning: Scene 3 TTS audio failed or has zero duration.")
subtitle_voice = Text(
voice_text_03, font_size=32, color=MY_BLACK,
width=config.frame_width - 2, should_center=True
).to_edge(DOWN, buff=0.5)
anim_keystone_time = 2.0
anim_indicate_time = 1.5
fade_out_duration = 1.0
total_anim_duration_planned = anim_keystone_time + anim_indicate_time
self.play(FadeIn(subtitle_voice, run_time=0.5))
self.play(
Create(self.keystone),
FadeIn(keystone_label, shift=UP*0.2),
run_time=anim_keystone_time
)
self.play(Indicate(self.keystone, color=MY_HIGHLIGHT_COLOR, scale_factor=1.1), run_time=anim_indicate_time)
if tracker.duration > 0:
elapsed_time = 0.5 + total_anim_duration_planned
time_for_fadeout = fade_out_duration
remaining_time = tracker.duration - elapsed_time - time_for_fadeout
if remaining_time > 0:
self.wait(remaining_time)
else:
self.wait(1.0)
self.play(FadeOut(subtitle_voice), FadeOut(keystone_label), run_time=fade_out_duration)
self.wait(0.5)
def play_scene_04(self):
"""Scene 4: Keystone insertion and force redirection."""
self.scene_time_tracker.set_value(0)
scene_num_04 = self.get_scene_number("04")
self.add(scene_num_04)
target_position = self.keystone.get_center() + DOWN * 1.5
gravity_on_keystone = Arrow(
start=self.keystone.get_center_of_mass() + UP * 0.5,
end=self.keystone.get_center_of_mass() + DOWN * 0.5,
buff=0.1, color=MY_FORCE_GRAVITY
)
left_force_point = self.keystone.get_critical_point(LEFT + UP*0.1) + RIGHT*0.1
right_force_point = self.keystone.get_critical_point(RIGHT + UP*0.1) + LEFT*0.1
force_in_left = Arrow(
start=left_force_point + LEFT * 0.8 + UP * 0.2,
end=left_force_point,
buff=0.1, color=MY_FORCE_COMPRESSION, max_tip_length_to_length_ratio=0.2
)
force_in_right = Arrow(
start=right_force_point + RIGHT * 0.8 + UP * 0.2,
end=right_force_point,
buff=0.1, color=MY_FORCE_COMPRESSION, max_tip_length_to_length_ratio=0.2
)
forces_on_keystone = VGroup(gravity_on_keystone, force_in_left, force_in_right)
force_out_left_point = self.keystone.get_critical_point(LEFT + DOWN*0.1) + RIGHT*0.1
force_out_right_point = self.keystone.get_critical_point(RIGHT + DOWN*0.1) + LEFT*0.1
force_out_left = Arrow(
start=force_out_left_point,
end=force_out_left_point + LEFT * 0.8 + DOWN * 0.5,
buff=0.1, color=MY_FORCE_COMPRESSION, max_tip_length_to_length_ratio=0.2
)
force_out_right = Arrow(
start=force_out_right_point,
end=force_out_right_point + RIGHT * 0.8 + DOWN * 0.5,
buff=0.1, color=MY_FORCE_COMPRESSION, max_tip_length_to_length_ratio=0.2
)
forces_from_keystone = VGroup(force_out_left, force_out_right)
voice_text_04 = "When the keystone is tapped into place, it wedges tightly. Gravity pulls down on it, and the side stones push inwards. Because of its shape, the keystone redirects these forces outwards and downwards into the stones below."
with custom_voiceover_tts(voice_text_04) as tracker:
if tracker.audio_path and tracker.duration > 0:
self.add_sound(tracker.audio_path, time_offset=0)
else:
print("Warning: Scene 4 TTS audio failed or has zero duration.")
subtitle_voice = Text(
voice_text_04, font_size=32, color=MY_BLACK,
width=config.frame_width - 2, should_center=True
).to_edge(DOWN, buff=0.5)
anim_insert_time = 1.5
anim_forces_on_time = 2.0
anim_forces_from_time = 2.0
fade_out_duration = 1.0
total_anim_duration_planned = anim_insert_time + anim_forces_on_time + anim_forces_from_time
self.play(FadeIn(subtitle_voice, run_time=0.5))
self.play(self.keystone.animate.move_to(target_position), run_time=anim_insert_time)
gravity_on_keystone.move_to(self.keystone.get_center_of_mass())
force_in_left.move_to(self.keystone.get_critical_point(LEFT + UP*0.1) + LEFT*0.4 + UP*0.1)
force_in_right.move_to(self.keystone.get_critical_point(RIGHT + UP*0.1) + RIGHT*0.4 + UP*0.1)
force_out_left.move_to(self.keystone.get_critical_point(LEFT + DOWN*0.1) + LEFT*0.4 + DOWN*0.25)
force_out_right.move_to(self.keystone.get_critical_point(RIGHT + DOWN*0.1) + RIGHT*0.4 + DOWN*0.25)
self.play(
Create(gravity_on_keystone),
Create(force_in_left),
Create(force_in_right),
run_time=anim_forces_on_time
)
self.play(
ReplacementTransform(force_in_left.copy().set_opacity(0), force_out_left),
ReplacementTransform(force_in_right.copy().set_opacity(0), force_out_right),
run_time=anim_forces_from_time
)
if tracker.duration > 0:
elapsed_time = 0.5 + total_anim_duration_planned
time_for_fadeout = fade_out_duration
remaining_time = tracker.duration - elapsed_time - time_for_fadeout
if remaining_time > 0:
self.wait(remaining_time)
else:
self.wait(1.0)
self.play(FadeOut(subtitle_voice), FadeOut(forces_on_keystone), FadeOut(forces_from_keystone), run_time=fade_out_duration)
self.wait(0.5)
def play_scene_05(self):
"""Scene 5: Show compression flow through the arch."""
self.scene_time_tracker.set_value(0)
scene_num_05 = self.get_scene_number("05")
self.add(scene_num_05)
compression_arrows = VGroup()
num_arrows_per_side = self.num_voussoirs_per_side + 1
mid_radius = (self.inner_radius + self.outer_radius) / 2
for i in range(num_arrows_per_side):
angle_start = 90*DEGREES + (i - 0.5) * self.voussoir_angle
angle_end = angle_start - self.voussoir_angle * 0.8
start_point = self.arch_center + mid_radius * np.array([np.cos(angle_start), np.sin(angle_start), 0])
end_point = self.arch_center + mid_radius * np.array([np.cos(angle_end), np.sin(angle_end), 0])
arrow = Arrow(start_point, end_point, buff=0.05, color=MY_FORCE_COMPRESSION, stroke_width=5, max_tip_length_to_length_ratio=0.3)
compression_arrows.add(arrow)
for i in range(num_arrows_per_side):
angle_start = 90*DEGREES - (i - 0.5) * self.voussoir_angle
angle_end = angle_start + self.voussoir_angle * 0.8
start_point = self.arch_center + mid_radius * np.array([np.cos(angle_start), np.sin(angle_start), 0])
end_point = self.arch_center + mid_radius * np.array([np.cos(angle_end), np.sin(angle_end), 0])
arrow = Arrow(start_point, end_point, buff=0.05, color=MY_FORCE_COMPRESSION, stroke_width=5, max_tip_length_to_length_ratio=0.3)
compression_arrows.add(arrow)
compression_text = Text(
"This creates COMPRESSION throughout the arch.", t2c={"COMPRESSION": MY_FORCE_COMPRESSION},
font_size=36, color=MY_BLACK
).shift(UP * 2.5)
stone_strength_text = Text(
"(Stone is very strong under compression!)",
font_size=30, color=MY_BLACK, slant=ITALIC
).next_to(compression_text, DOWN, buff=0.3)
self.compression_arrows = compression_arrows
voice_text_05 = "This locking action creates a continuous line of compression running through all the stones. It squeezes them together tightly. Importantly, stone is very strong when squeezed like this."
with custom_voiceover_tts(voice_text_05) as tracker:
if tracker.audio_path and tracker.duration > 0:
self.add_sound(tracker.audio_path, time_offset=0)
else:
print("Warning: Scene 5 TTS audio failed or has zero duration.")
subtitle_voice = Text(
voice_text_05, font_size=32, color=MY_BLACK,
width=config.frame_width - 2, should_center=True
).to_edge(DOWN, buff=0.5)
anim_arrow_time = 3.0
anim_text_time = 2.0
fade_out_duration = 1.0
total_anim_duration_planned = anim_arrow_time + anim_text_time
self.play(FadeIn(subtitle_voice, run_time=0.5))
self.play(AnimationGroup(*[Create(arrow) for arrow in self.compression_arrows], lag_ratio=0.1), run_time=anim_arrow_time)
self.play(
FadeIn(compression_text, shift=UP*0.2),
FadeIn(stone_strength_text, shift=UP*0.2),
run_time=anim_text_time
)
if tracker.duration > 0:
elapsed_time = 0.5 + total_anim_duration_planned
time_for_fadeout = fade_out_duration
remaining_time = tracker.duration - elapsed_time - time_for_fadeout
if remaining_time > 0:
self.wait(remaining_time)
else:
self.wait(1.0)
self.play(FadeOut(subtitle_voice), FadeOut(compression_text), FadeOut(stone_strength_text), run_time=fade_out_duration)
self.wait(0.5)
def play_scene_06(self):
"""Scene 6: Transfer to foundations, thrust, conclusion."""
self.scene_time_tracker.set_value(0)
scene_num_06 = self.get_scene_number("06")
self.add(scene_num_06)
self.add(self.compression_arrows)
thrust_left_start = self.abutments[0].get_corner(UR) + LEFT*0.1 + UP*0.1
thrust_right_start = self.abutments[1].get_corner(UL) + RIGHT*0.1 + UP*0.1
thrust_left = Arrow(thrust_left_start, thrust_left_start + LEFT * 1.5, buff=0.1, color=MY_FORCE_THRUST, stroke_width=6)
thrust_right = Arrow(thrust_right_start, thrust_right_start + RIGHT * 1.5, buff=0.1, color=MY_FORCE_THRUST, stroke_width=6)
thrust_arrows = VGroup(thrust_left, thrust_right)
transfer_text = Text("Compression transfers weight to the foundations (abutments).", font_size=36, color=MY_BLACK).shift(UP * 3.0)
thrust_text = Text("The arch also pushes outwards (thrust).", t2c={"thrust": MY_FORCE_THRUST}, font_size=36, color=MY_BLACK).next_to(transfer_text, DOWN, buff=0.3)
conclusion_text = Text("The Keystone locks it all together!", font_size=42, color=MY_KEYSTONE_COLOR, weight=BOLD).shift(DOWN * 3.5)
voice_text_06 = "These compressive forces travel down through the stones, safely transferring the weight to the ground supports, called abutments. The arch also generates an outward push, called thrust, that the abutments must resist. The keystone is the critical piece that locks the entire structure, turning gravity into stable compression."
with custom_voiceover_tts(voice_text_06) as tracker:
if tracker.audio_path and tracker.duration > 0:
self.add_sound(tracker.audio_path, time_offset=0)
else:
print("Warning: Scene 6 TTS audio failed or has zero duration.")
subtitle_voice = Text(
voice_text_06, font_size=32, color=MY_BLACK,
width=config.frame_width - 2, should_center=True
).to_edge(DOWN, buff=0.5)
anim_text1_time = 2.0
anim_thrust_time = 2.0
anim_conclusion_time = 1.5
fade_out_duration = 1.0
total_anim_duration_planned = anim_text1_time + anim_thrust_time + anim_conclusion_time
self.play(FadeIn(subtitle_voice, run_time=0.5))
self.play(FadeIn(transfer_text, shift=UP*0.2), run_time=anim_text1_time)
self.play(
Create(thrust_arrows),
FadeIn(thrust_text, shift=UP*0.2),
run_time=anim_thrust_time
)
self.play(
Indicate(self.keystone, color=MY_HIGHLIGHT_COLOR, scale_factor=1.2),
Write(conclusion_text),
run_time=anim_conclusion_time
)
if tracker.duration > 0:
elapsed_time = 0.5 + total_anim_duration_planned
time_for_fadeout = fade_out_duration
remaining_time = tracker.duration - elapsed_time - time_for_fadeout
if remaining_time > 0:
self.wait(remaining_time)
else:
self.wait(1.0)
self.play(FadeOut(subtitle_voice), run_time=fade_out_duration)
self.wait(2)
if __name__ == "__main__":
config.pixel_height = 1080
config.pixel_width = 1920
config.frame_rate = 30
config.output_file = "CombinedScene"
config.disable_caching = True
config.media_dir = "./#(output_video)"
scene = CombinedScene()
scene.render()
print(f"Scene rendering finished. Output in: {config.media_dir}")