slidemovie 0.2.2__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: slidemovie
3
- Version: 0.2.2
3
+ Version: 0.4.0
4
4
  Summary: Markdown and PowerPoint to narration video generator
5
5
  Author: Katsutoshi Seki
6
6
  License-Expression: MIT
@@ -51,7 +51,7 @@ Dynamic: license-file
51
51
  ## ✨ Features
52
52
 
53
53
  * **Markdown-Based**: Write your slide content and narration script in a single text file.
54
- * **AI Narration**: Automatically generates natural voiceovers using **Google Gemini** or **OpenAI** (via `multiai-tts`).
54
+ * **AI Narration**: Automatically generates natural voiceovers using **Google Gemini**, **OpenAI** or **Azure** (via `multiai-tts`).
55
55
  * **PowerPoint Integration**: Use PowerPoint's AI "Designer" to create professional visuals instantly.
56
56
  * **No Video Editing**: Audio and visuals are automatically synchronized.
57
57
  * **Incremental Builds**: Only regenerates changed slides to save time and API costs.
@@ -69,7 +69,7 @@ Dynamic: license-file
69
69
  pip install slidemovie
70
70
  ```
71
71
 
72
- *Note: You also need to install **FFmpeg**, **Pandoc**, **LibreOffice**, and **Poppler**, and set up your **AI API Key** (Google or OpenAI). See the [documentation](https://sekika.github.io/slidemovie/installation.html) for details.*
72
+ *Note: You also need to install **FFmpeg**, **Pandoc**, **LibreOffice**, and **Poppler**, and set up your **AI API Key** (Google or OpenAI). See the [documentation](https://sekika.github.io/slidemovie/installation/) for details.*
73
73
 
74
74
  ### 2. Create a Project
75
75
 
@@ -21,7 +21,7 @@
21
21
  ## ✨ Features
22
22
 
23
23
  * **Markdown-Based**: Write your slide content and narration script in a single text file.
24
- * **AI Narration**: Automatically generates natural voiceovers using **Google Gemini** or **OpenAI** (via `multiai-tts`).
24
+ * **AI Narration**: Automatically generates natural voiceovers using **Google Gemini**, **OpenAI** or **Azure** (via `multiai-tts`).
25
25
  * **PowerPoint Integration**: Use PowerPoint's AI "Designer" to create professional visuals instantly.
26
26
  * **No Video Editing**: Audio and visuals are automatically synchronized.
27
27
  * **Incremental Builds**: Only regenerates changed slides to save time and API costs.
@@ -39,7 +39,7 @@
39
39
  pip install slidemovie
40
40
  ```
41
41
 
42
- *Note: You also need to install **FFmpeg**, **Pandoc**, **LibreOffice**, and **Poppler**, and set up your **AI API Key** (Google or OpenAI). See the [documentation](https://sekika.github.io/slidemovie/installation.html) for details.*
42
+ *Note: You also need to install **FFmpeg**, **Pandoc**, **LibreOffice**, and **Poppler**, and set up your **AI API Key** (Google or OpenAI). See the [documentation](https://sekika.github.io/slidemovie/installation/) for details.*
43
43
 
44
44
  ### 2. Create a Project
45
45
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "slidemovie"
7
- version = "0.2.2"
7
+ version = "0.4.0"
8
8
  description = "Markdown and PowerPoint to narration video generator"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -14,6 +14,7 @@ logging.basicConfig(
14
14
  )
15
15
  logger = logging.getLogger(__name__)
16
16
 
17
+
17
18
  def main():
18
19
  """
19
20
  Entry point for the slidemovie command-line tool.
@@ -76,11 +77,18 @@ def main():
76
77
  )
77
78
 
78
79
  # --- TTS Settings Options (CLI Overrides) ---
79
- parser.add_argument("--tts-provider", help="TTS Provider (e.g., google, openai)")
80
+ parser.add_argument(
81
+ "--tts-provider",
82
+ help="TTS Provider (e.g., google, openai)")
80
83
  parser.add_argument("--tts-model", help="TTS Model name")
81
84
  parser.add_argument("--tts-voice", help="TTS Voice/Speaker setting")
82
- parser.add_argument("--prompt", help="Override TTS system prompt (automatically enables prompt usage)")
83
- parser.add_argument("--no-prompt", action="store_true", help="Disable TTS system prompt")
85
+ parser.add_argument(
86
+ "--prompt",
87
+ help="Override TTS system prompt (automatically enables prompt usage)")
88
+ parser.add_argument(
89
+ "--no-prompt",
90
+ action="store_true",
91
+ help="Disable TTS system prompt")
84
92
 
85
93
  # --- Other Options ---
86
94
  parser.add_argument(
@@ -89,23 +97,24 @@ def main():
89
97
  help="Enable debug mode (Verbose logging, etc)."
90
98
  )
91
99
 
92
- args = parser.parse_args()
93
-
94
- # Exit if no action is specified
95
- if not args.pptx and not args.video:
96
- parser.print_help()
97
- sys.exit(1)
98
-
99
100
  # 1. Initialize Movie instance (Load configuration files)
100
101
  try:
101
102
  movie = slidemovie.Movie()
102
103
  except NameError:
103
- logger.error("Movie class is not defined. Make sure to import it correctly.")
104
+ logger.error(
105
+ "Movie class is not defined. Make sure to import it correctly.")
104
106
  sys.exit(1)
105
107
  except Exception as e:
106
108
  logger.error(f"Failed to initialize Movie class: {e}")
107
109
  sys.exit(1)
108
110
 
111
+ args = parser.parse_args()
112
+
113
+ # Exit if no action is specified
114
+ if not args.pptx and not args.video:
115
+ parser.print_help()
116
+ sys.exit(1)
117
+
109
118
  # 2. Override settings with CLI options
110
119
  if args.tts_provider:
111
120
  movie.tts_provider = args.tts_provider
@@ -118,7 +127,9 @@ def main():
118
127
  movie.tts_use_prompt = True
119
128
  if args.no_prompt:
120
129
  movie.tts_use_prompt = False
121
-
130
+ if args.filename:
131
+ movie.output_filename = args.filename
132
+
122
133
  if args.debug:
123
134
  movie.ffmpeg_loglevel = 'info'
124
135
  movie.show_skip = True
@@ -131,13 +142,13 @@ def main():
131
142
  try:
132
143
  if args.sub:
133
144
  # Hierarchical Mode (Parent/Child)
134
- logger.info(f"Configuring subproject paths: {args.project_name}/{args.sub}")
145
+ logger.info(
146
+ f"Configuring subproject paths: {args.project_name}/{args.sub}")
135
147
  movie.configure_subproject_paths(
136
148
  parent_project_name=args.project_name,
137
149
  subproject_name=args.sub,
138
150
  source_parent_dir=args.source_dir,
139
- output_root_dir=args.output_root,
140
- output_filename=args.filename
151
+ output_root_dir=args.output_root
141
152
  )
142
153
  else:
143
154
  # Standard Mode (Flat)
@@ -145,8 +156,7 @@ def main():
145
156
  movie.configure_project_paths(
146
157
  project_name=args.project_name,
147
158
  source_dir=args.source_dir,
148
- output_root_dir=args.output_root,
149
- output_filename=args.filename
159
+ output_root_dir=args.output_root
150
160
  )
151
161
  except Exception as e:
152
162
  logger.error(f"Failed to configure paths: {e}")
@@ -162,7 +172,8 @@ def main():
162
172
  movie.build_slide_pptx()
163
173
  logger.info("PPTX generation process finished.")
164
174
  if not args.video:
165
- logger.info("Please edit the generated PPTX file and run with --video to create the movie.")
175
+ logger.info(
176
+ "Please edit the generated PPTX file and run with --video to create the movie.")
166
177
 
167
178
  # Generate Video (--video)
168
179
  if args.video:
@@ -170,8 +181,9 @@ def main():
170
181
  logger.info("MODE: Build All Video Assets")
171
182
  logger.info("=" * 60)
172
183
  movie.build_all()
173
-
184
+
174
185
  logger.info("All video processes finished.")
175
186
 
187
+
176
188
  if __name__ == "__main__":
177
189
  main()
@@ -14,6 +14,7 @@ from datetime import datetime
14
14
  # Configure module logger
15
15
  logger = logging.getLogger(__name__)
16
16
 
17
+
17
18
  class Movie():
18
19
  """
19
20
  A class to automatically generate narration videos based on PowerPoint slides and Markdown notes.
@@ -55,10 +56,12 @@ class Movie():
55
56
  - pandoc
56
57
  """
57
58
  required_tools = ['ffmpeg', 'ffprobe', 'pandoc']
58
- missing_tools = [tool for tool in required_tools if not shutil.which(tool)]
59
-
59
+ missing_tools = [
60
+ tool for tool in required_tools if not shutil.which(tool)]
61
+
60
62
  if missing_tools:
61
- logger.error(f"Required external commands not found: {', '.join(missing_tools)}")
63
+ logger.error(
64
+ f"Required external commands not found: {', '.join(missing_tools)}")
62
65
  logger.error("Please install them before running this tool.")
63
66
  sys.exit(1)
64
67
 
@@ -86,6 +89,7 @@ class Movie():
86
89
  show_skip (bool): Whether to log skipped tasks. Default: False.
87
90
  max_retry (int): Max retries for TTS API errors. Default: 2.
88
91
  output_root (str): Root directory for video output. Default: None.
92
+ output_filename (str): Output video filename (without extension). Default: None (Uses project ID).
89
93
  """
90
94
  return {
91
95
  # TTS settings
@@ -116,18 +120,19 @@ class Movie():
116
120
  "show_skip": False,
117
121
  "max_retry": 2,
118
122
 
119
- # Output path setting (Used if not provided via CLI)
120
- "output_root": None
123
+ # Output path settings (Used if not provided via CLI)
124
+ "output_root": None,
125
+ "output_filename": None
121
126
  }
122
127
 
123
128
  def _load_settings(self):
124
129
  """
125
130
  Loads settings from JSON files and merges them with defaults.
126
-
131
+
127
132
  It looks for configuration in:
128
133
  1. ~/.config/slidemovie/config.json
129
134
  2. ./config.json
130
-
135
+
131
136
  Finally, it sets the configuration values as instance attributes.
132
137
  """
133
138
  # 1. Get default settings
@@ -139,10 +144,11 @@ class Movie():
139
144
  try:
140
145
  os.makedirs(config_dir, exist_ok=True)
141
146
  except OSError as e:
142
- logger.warning(f"Failed to create config directory {config_dir}: {e}")
147
+ logger.warning(
148
+ f"Failed to create config directory {config_dir}: {e}")
143
149
 
144
150
  home_config_path = os.path.join(config_dir, "config.json")
145
-
151
+
146
152
  if not os.path.exists(home_config_path):
147
153
  # Create default config file if it doesn't exist
148
154
  try:
@@ -162,7 +168,7 @@ class Movie():
162
168
 
163
169
  # 3. Process ./config.json (Current directory)
164
170
  local_config_path = "./config.json"
165
-
171
+
166
172
  if os.path.exists(local_config_path):
167
173
  try:
168
174
  with open(local_config_path, 'r', encoding='utf-8') as f:
@@ -173,7 +179,7 @@ class Movie():
173
179
  logger.warning(f"Failed to load {local_config_path}: {e}")
174
180
 
175
181
  # 4. Set attributes
176
-
182
+
177
183
  # Special handling: Convert screen_size from list to tuple
178
184
  if "screen_size" in config and isinstance(config["screen_size"], list):
179
185
  config["screen_size"] = tuple(config["screen_size"])
@@ -182,18 +188,16 @@ class Movie():
182
188
  for key, value in config.items():
183
189
  setattr(self, key, value)
184
190
 
185
-
186
- def configure_project_paths(self, project_name, source_dir, output_root_dir=None, output_filename=None):
191
+ def configure_project_paths(
192
+ self, project_name, source_dir, output_root_dir=None):
187
193
  """
188
194
  Configures paths for a standard (flat) project structure.
189
-
195
+
190
196
  Args:
191
197
  project_name (str): The name/ID of the project.
192
198
  source_dir (str): The directory containing source files (.md, .pptx).
193
- output_root_dir (str, optional): Root directory for video output.
199
+ output_root_dir (str, optional): Root directory for video output.
194
200
  Defaults to `self.output_root` or `{source_dir}/movie`.
195
- output_filename (str, optional): Filename for the output video (without extension).
196
- Defaults to `project_name`.
197
201
  """
198
202
  # Determine output root directory
199
203
  target_root = None
@@ -206,22 +210,25 @@ class Movie():
206
210
  else:
207
211
  target_root = f'{source_dir}/movie'
208
212
  is_automatic_path = True
209
-
213
+
210
214
  # Expand path
211
215
  target_root = os.path.expanduser(target_root)
212
216
 
213
217
  # Handle directory existence
214
218
  if is_automatic_path:
215
- # If the path is automatically determined, create it if it doesn't exist
219
+ # If the path is automatically determined, create it if it doesn't
220
+ # exist
216
221
  if not os.path.isdir(target_root):
217
222
  try:
218
223
  os.makedirs(target_root, exist_ok=True)
219
224
  logger.info(f'Created output directory: {target_root}')
220
225
  except OSError as e:
221
- logger.error(f'Failed to create directory {target_root}: {e}')
226
+ logger.error(
227
+ f'Failed to create directory {target_root}: {e}')
222
228
  sys.exit(1)
223
229
  else:
224
- # If the path is explicitly specified (CLI or Config), strict check is applied
230
+ # If the path is explicitly specified (CLI or Config), strict check
231
+ # is applied
225
232
  if not os.path.isdir(target_root):
226
233
  logger.error(f'Directory {target_root} does not exist.')
227
234
  sys.exit(1)
@@ -229,33 +236,34 @@ class Movie():
229
236
  # Set member variables
230
237
  self.source_dir = source_dir
231
238
  self.project_id = project_name
232
-
233
- if not output_filename:
234
- output_filename = project_name
239
+
240
+ # Determine output filename
241
+ # Priority: self.output_filename (Config/CLI) > project_name (Default)
242
+ final_filename = self.output_filename if self.output_filename else project_name
235
243
 
236
244
  # Construct file paths
237
245
  self.md_file = f'{self.source_dir}/{project_name}.md'
238
246
  self.status_file = f'{self.source_dir}/status.json'
239
247
  self.video_length_file = f'{self.source_dir}/video_length.csv'
240
-
248
+
241
249
  # Create intermediate/output directories
242
250
  self.movie_dir = f'{target_root}/{project_name}'
243
251
  if not os.path.isdir(self.movie_dir):
244
252
  os.mkdir(self.movie_dir)
245
253
 
246
254
  self.slide_file = f'{self.source_dir}/{project_name}.pptx'
247
- self.video_file = f'{self.movie_dir}/{output_filename}.mp4'
255
+ self.video_file = f'{self.movie_dir}/{final_filename}.mp4'
248
256
 
249
- def configure_subproject_paths(self, parent_project_name, subproject_name, source_parent_dir, output_root_dir=None, output_filename=None):
257
+ def configure_subproject_paths(
258
+ self, parent_project_name, subproject_name, source_parent_dir, output_root_dir=None):
250
259
  """
251
260
  Configures paths for a nested project structure (Parent Folder -> Child Folder).
252
-
261
+
253
262
  Args:
254
263
  parent_project_name (str): The name of the parent project.
255
264
  subproject_name (str): The name of the subproject (child folder name).
256
265
  source_parent_dir (str): The directory containing the parent project folder.
257
266
  output_root_dir (str, optional): Root directory for video output.
258
- output_filename (str, optional): Filename for the output video (without extension).
259
267
  """
260
268
  # Determine output root directory
261
269
  target_root = None
@@ -268,64 +276,68 @@ class Movie():
268
276
  else:
269
277
  target_root = f'{source_parent_dir}/movie'
270
278
  is_automatic_path = True
271
-
279
+
272
280
  # Expand path
273
281
  target_root = os.path.expanduser(target_root)
274
282
 
275
283
  # Handle directory existence
276
284
  if is_automatic_path:
277
- # If the path is automatically determined, create it if it doesn't exist
285
+ # If the path is automatically determined, create it if it doesn't
286
+ # exist
278
287
  if not os.path.isdir(target_root):
279
288
  try:
280
289
  os.makedirs(target_root, exist_ok=True)
281
290
  logger.info(f'Created output directory: {target_root}')
282
291
  except OSError as e:
283
- logger.error(f'Failed to create directory {target_root}: {e}')
292
+ logger.error(
293
+ f'Failed to create directory {target_root}: {e}')
284
294
  sys.exit(1)
285
295
  else:
286
- # If the path is explicitly specified (CLI or Config), strict check is applied
296
+ # If the path is explicitly specified (CLI or Config), strict check
297
+ # is applied
287
298
  if not os.path.isdir(target_root):
288
299
  logger.error(f'Directory {target_root} does not exist.')
289
300
  sys.exit(1)
290
301
 
291
302
  # Source directory is "Parent/Child"
292
303
  self.source_dir = f'{source_parent_dir}/{subproject_name}'
293
-
304
+
294
305
  # Project ID format: "Parent-Child"
295
306
  self.project_id = f'{parent_project_name}-{subproject_name}'
296
-
297
- if not output_filename:
298
- output_filename = self.project_id
307
+
308
+ # Determine output filename
309
+ # Priority: self.output_filename (Config/CLI) > self.project_id (Default)
310
+ final_filename = self.output_filename if self.output_filename else self.project_id
299
311
 
300
312
  # Construct file paths
301
313
  self.md_file = f'{self.source_dir}/{subproject_name}.md'
302
314
  self.status_file = f'{self.source_dir}/status.json'
303
315
  self.video_length_file = f'{self.source_dir}/video_length.csv'
304
-
316
+
305
317
  # Create output directory hierarchy (movie/parent/child)
306
318
  parent_movie_dir = f'{target_root}/{parent_project_name}'
307
319
  self.movie_dir = f'{parent_movie_dir}/{subproject_name}'
308
-
320
+
309
321
  if not os.path.isdir(parent_movie_dir):
310
322
  os.mkdir(parent_movie_dir)
311
323
  if not os.path.isdir(self.movie_dir):
312
324
  os.mkdir(self.movie_dir)
313
325
 
314
326
  self.slide_file = f'{self.source_dir}/{subproject_name}.pptx'
315
- self.video_file = f'{self.movie_dir}/{output_filename}.mp4'
327
+ self.video_file = f'{self.movie_dir}/{final_filename}.mp4'
316
328
 
317
329
  def build_all(self):
318
330
  """
319
331
  Orchestrates the creation of the complete video from Markdown and PPTX files.
320
-
321
- Note: This does not update the PPTX file from Markdown.
332
+
333
+ Note: This does not update the PPTX file from Markdown.
322
334
  Run `build_slide_pptx()` beforehand if necessary.
323
335
  """
324
336
  self._check_external_tools()
325
337
  if not os.path.isfile(self.md_file):
326
338
  logger.error(f'{self.md_file} does not exist.')
327
339
  sys.exit(1)
328
-
340
+
329
341
  # 1. Generate narration audio from Markdown notes
330
342
  self.build_slide_audio()
331
343
  # 2. Generate slide images from PPTX
@@ -375,7 +387,8 @@ class Movie():
375
387
  audio_state["wav_file"]
376
388
  )
377
389
 
378
- # Regeneration check (Status mismatch OR Hash mismatch OR File missing)
390
+ # Regeneration check (Status mismatch OR Hash mismatch OR File
391
+ # missing)
379
392
  if (audio_status != "generated" or
380
393
  saved_notes_hash != current_notes_hash or
381
394
  not os.path.isfile(wav_path)):
@@ -384,7 +397,8 @@ class Movie():
384
397
 
385
398
  add_prompt = audio_state.get("additional_prompt", "")
386
399
  if norm == "":
387
- logger.error(f'Error: "::: notes" not found in {slide_id}.')
400
+ logger.error(
401
+ f'Error: "::: notes" not found in {slide_id}.')
388
402
  sys.exit()
389
403
  self._speak_to_wav(
390
404
  norm, wav_path, additional_prompt=add_prompt)
@@ -533,7 +547,8 @@ class Movie():
533
547
  f"[SKIP] {slide_id} (Video: unchanged/Source:{video_file_src})")
534
548
  continue
535
549
 
536
- logger.info(f"Converting video: {video_file_src} -> {slide_id}.mp4")
550
+ logger.info(
551
+ f"Converting video: {video_file_src} -> {slide_id}.mp4")
537
552
 
538
553
  # FFmpeg command: Resize + Audio re-encode
539
554
  cmd = [
@@ -580,7 +595,8 @@ class Movie():
580
595
  png_file = os.path.join(self.movie_dir, f"{slide_id}.png")
581
596
  wav_file = os.path.join(self.movie_dir, f"{slide_id}.wav")
582
597
 
583
- if not os.path.isfile(png_file) or not os.path.isfile(wav_file):
598
+ if not os.path.isfile(
599
+ png_file) or not os.path.isfile(wav_file):
584
600
  # Skip if assets are missing
585
601
  logger.warning(f"Material missing, skipping: {slide_id}")
586
602
  continue
@@ -1040,7 +1056,8 @@ class Movie():
1040
1056
  current_config = self._get_build_config()
1041
1057
 
1042
1058
  if stored_config is None:
1043
- logger.info("No build_config in state file. Applying current settings.")
1059
+ logger.info(
1060
+ "No build_config in state file. Applying current settings.")
1044
1061
  state["build_config"] = current_config
1045
1062
  self._save_audio_state(state)
1046
1063
  stored_config = current_config
@@ -1048,7 +1065,8 @@ class Movie():
1048
1065
  if stored_config != current_config:
1049
1066
  import pprint
1050
1067
  logger.error("build_config inconsistency detected.")
1051
- logger.error("Changing resolution/FPS mid-process is not supported. Aborting.")
1068
+ logger.error(
1069
+ "Changing resolution/FPS mid-process is not supported. Aborting.")
1052
1070
  logger.error("-" * 40)
1053
1071
  logger.error("[Stored Config]")
1054
1072
  logger.error(pprint.pformat(stored_config))
@@ -1064,7 +1082,8 @@ class Movie():
1064
1082
 
1065
1083
  # Auto-fill if missing (Migration)
1066
1084
  if stored_tts is None:
1067
- logger.info("No TTS config in state file. Applying current settings.")
1085
+ logger.info(
1086
+ "No TTS config in state file. Applying current settings.")
1068
1087
  state["tts_config"] = current_tts
1069
1088
  self._save_audio_state(state)
1070
1089
 
@@ -1080,13 +1099,15 @@ class Movie():
1080
1099
  logger.warning("[Current Config (Now)]")
1081
1100
  logger.warning(pprint.pformat(current_tts))
1082
1101
  logger.warning("=" * 60)
1083
- logger.warning("Generating audio with different settings may result in inconsistent audio in the video.")
1102
+ logger.warning(
1103
+ "Generating audio with different settings may result in inconsistent audio in the video.")
1084
1104
 
1085
1105
  while True:
1086
1106
  choice = input(
1087
1107
  "Select action: 1) Ignore and Continue (Overwrite config) 2) Abort [1/2]: ").strip()
1088
1108
  if choice == '1':
1089
- logger.info("Applying new settings and continuing. Updating state file.")
1109
+ logger.info(
1110
+ "Applying new settings and continuing. Updating state file.")
1090
1111
  state["tts_config"] = current_tts
1091
1112
  self._save_audio_state(state)
1092
1113
  break
@@ -1286,17 +1307,21 @@ class Movie():
1286
1307
  additional_prompt (str): Additional prompt for specific slides.
1287
1308
  """
1288
1309
  client = multiai_tts.Prompt()
1289
- client.set_tts_model(self.tts_provider, self.tts_model)
1290
1310
  if self.tts_provider == 'openai':
1311
+ client.set_tts_model(self.tts_provider, self.tts_model)
1291
1312
  client.tts_voice_openai = self.tts_voice
1292
1313
  if self.tts_provider == 'google':
1314
+ client.set_tts_model(self.tts_provider, self.tts_model)
1293
1315
  client.tts_voice_google = self.tts_voice
1316
+ if self.tts_provider == 'azure':
1317
+ client.set_tts_provider(self.tts_provider)
1318
+ client.tts_voice_azure = self.tts_voice
1294
1319
 
1295
1320
  if self.tts_use_prompt:
1296
1321
  full_prompt_text = f'{self.prompt}{additional_prompt}\n{text}'
1297
1322
  else:
1298
1323
  full_prompt_text = text
1299
-
1324
+
1300
1325
  for attempt in range(self.max_retry):
1301
1326
  client.save_tts(full_prompt_text, wav_path)
1302
1327
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: slidemovie
3
- Version: 0.2.2
3
+ Version: 0.4.0
4
4
  Summary: Markdown and PowerPoint to narration video generator
5
5
  Author: Katsutoshi Seki
6
6
  License-Expression: MIT
@@ -51,7 +51,7 @@ Dynamic: license-file
51
51
  ## ✨ Features
52
52
 
53
53
  * **Markdown-Based**: Write your slide content and narration script in a single text file.
54
- * **AI Narration**: Automatically generates natural voiceovers using **Google Gemini** or **OpenAI** (via `multiai-tts`).
54
+ * **AI Narration**: Automatically generates natural voiceovers using **Google Gemini**, **OpenAI** or **Azure** (via `multiai-tts`).
55
55
  * **PowerPoint Integration**: Use PowerPoint's AI "Designer" to create professional visuals instantly.
56
56
  * **No Video Editing**: Audio and visuals are automatically synchronized.
57
57
  * **Incremental Builds**: Only regenerates changed slides to save time and API costs.
@@ -69,7 +69,7 @@ Dynamic: license-file
69
69
  pip install slidemovie
70
70
  ```
71
71
 
72
- *Note: You also need to install **FFmpeg**, **Pandoc**, **LibreOffice**, and **Poppler**, and set up your **AI API Key** (Google or OpenAI). See the [documentation](https://sekika.github.io/slidemovie/installation.html) for details.*
72
+ *Note: You also need to install **FFmpeg**, **Pandoc**, **LibreOffice**, and **Poppler**, and set up your **AI API Key** (Google or OpenAI). See the [documentation](https://sekika.github.io/slidemovie/installation/) for details.*
73
73
 
74
74
  ### 2. Create a Project
75
75
 
@@ -36,11 +36,11 @@ def test_cli_pptx_mode(mock_movie_class):
36
36
  mock_movie_class.assert_called_once()
37
37
 
38
38
  # Check if default path configuration was called (Flat mode)
39
+ # [Fix] output_filename argument removed from call signature
39
40
  mock_instance.configure_project_paths.assert_called_with(
40
41
  project_name='MyProject',
41
42
  source_dir='.',
42
- output_root_dir=None,
43
- output_filename=None
43
+ output_root_dir=None
44
44
  )
45
45
 
46
46
  # Check if correct build method was called
@@ -62,17 +62,29 @@ def test_cli_video_mode_subproject(mock_movie_class):
62
62
  assert mock_instance.show_skip is True
63
63
 
64
64
  # Verify subproject path configuration
65
+ # [Fix] output_filename argument removed from call signature
65
66
  mock_instance.configure_subproject_paths.assert_called_with(
66
67
  parent_project_name='ParentProj',
67
68
  subproject_name='ChildProj',
68
69
  source_parent_dir='.',
69
- output_root_dir=None,
70
- output_filename=None
70
+ output_root_dir=None
71
71
  )
72
72
 
73
73
  # Verify build_all was called
74
74
  mock_instance.build_all.assert_called_once()
75
75
 
76
+ def test_cli_filename_argument(mock_movie_class):
77
+ """Test if -f/--filename argument sets the movie attribute correctly."""
78
+ # Test short flag -f
79
+ test_args = ['slidemovie', 'Proj', '--video', '-f', 'custom_name']
80
+
81
+ mock_instance = mock_movie_class.return_value
82
+
83
+ with patch.object(sys, 'argv', test_args):
84
+ cli.main()
85
+
86
+ assert mock_instance.output_filename == 'custom_name'
87
+
76
88
  def test_cli_override_tts_options(mock_movie_class):
77
89
  """Test if CLI arguments override TTS configuration."""
78
90
  test_args = [
@@ -27,28 +27,26 @@ def movie(mock_tools):
27
27
  """Fixture to create a Movie instance."""
28
28
  m = Movie()
29
29
  # Reset output_root to None to ensure tests rely on the source_dir structure
30
- # and ignore any user-defined 'output_root' in local config.json.
31
30
  m.output_root = None
31
+ # Ensure output_filename is None by default (mimic init state)
32
+ m.output_filename = None
32
33
  return m
33
34
 
34
35
  class TestMovieConfig:
35
36
  def test_default_settings(self, movie):
36
37
  """Test if settings are loaded (checking key existence)."""
37
- # We check existence because actual values might be overridden by local config
38
38
  assert hasattr(movie, 'tts_provider')
39
39
  assert hasattr(movie, 'screen_size')
40
+ # [Fix] Check for output_filename
41
+ assert hasattr(movie, 'output_filename')
40
42
 
41
43
  def test_load_settings_override(self, mock_tools, tmp_path):
42
44
  """Test overriding settings via config.json logic."""
43
- # Note: Since _load_settings is called in __init__, testing exact loading
44
- # behavior without mocking open() globally is complex.
45
- # Here we just verify the instance attributes can be set.
46
45
  pass
47
46
 
48
47
  class TestPathConfiguration:
49
48
  def test_configure_project_paths_flat(self, movie, tmp_path):
50
49
  """Test path configuration for standard (flat) mode."""
51
- # Ensure output_root is None so it falls back to source_dir/movie
52
50
  movie.output_root = None
53
51
 
54
52
  source_dir = tmp_path / "src"
@@ -66,6 +64,26 @@ class TestPathConfiguration:
66
64
  expected_movie_dir = source_dir / "movie" / "test_proj"
67
65
  assert movie.movie_dir == str(expected_movie_dir)
68
66
  assert os.path.exists(movie.movie_dir)
67
+
68
+ # [Fix] Verify default video filename (same as project name)
69
+ assert movie.video_file.endswith("test_proj.mp4")
70
+
71
+ def test_configure_project_paths_with_custom_filename(self, movie, tmp_path):
72
+ """Test path configuration with custom output_filename attribute set."""
73
+ source_dir = tmp_path / "src"
74
+ source_dir.mkdir()
75
+
76
+ # Manually set the attribute (as CLI or Config would do)
77
+ movie.output_filename = "custom_output_name"
78
+
79
+ movie.configure_project_paths(
80
+ project_name="test_proj",
81
+ source_dir=str(source_dir)
82
+ )
83
+
84
+ # Verify the filename uses the custom name, not project name
85
+ assert movie.video_file.endswith("custom_output_name.mp4")
86
+ assert "test_proj.mp4" not in movie.video_file
69
87
 
70
88
  def test_configure_subproject_paths(self, movie, tmp_path):
71
89
  """Test path configuration for subproject (Parent/Child) mode."""
@@ -86,6 +104,9 @@ class TestPathConfiguration:
86
104
  # Output: parent/movie/parent_proj/child_sub
87
105
  expected_movie_dir = parent_dir / "movie" / "parent_proj" / "child_sub"
88
106
  assert movie.movie_dir == str(expected_movie_dir)
107
+
108
+ # [Fix] Verify default filename (Parent-Child)
109
+ assert movie.video_file.endswith("parent_proj-child_sub.mp4")
89
110
 
90
111
  class TestMarkdownProcessing:
91
112
  def test_ensure_slide_ids(self, movie, tmp_path):
@@ -147,7 +168,7 @@ class TestBuildLogic:
147
168
  movie.slide_file = str(tmp_path / "test.pptx")
148
169
  movie.source_dir = str(tmp_path)
149
170
 
150
- # [Fix] Manually set project_id as it is required by _init_audio_state
171
+ # Manually set project_id as it is required by _init_audio_state
151
172
  movie.project_id = "test_project"
152
173
 
153
174
  # Mock status file
File without changes
File without changes
File without changes