import gc import glob import io import itertools import os import random import shutil import subprocess from contextlib import redirect_stdout from typing import List from PIL import Image, ImageFont from loguru import logger from moviepy import ( AudioFileClip, ColorClip, CompositeAudioClip, CompositeVideoClip, ImageClip, TextClip, VideoFileClip, afx, ) from moviepy.video.tools.subtitles import SubtitlesClip from app.models import const from app.models.schema import ( MaterialInfo, VideoAspect, VideoConcatMode, VideoParams, VideoTransitionMode, ) from app.services.utils import video_effects from app.utils import file_security, utils class SubClippedVideoClip: def __init__(self, file_path, start_time=None, end_time=None, width=None, height=None, duration=None): self.file_path = file_path self.start_time = start_time self.end_time = end_time self.width = width self.height = height if duration is None: self.duration = end_time - start_time else: self.duration = duration def __str__(self): return f"SubClippedVideoClip(file_path={self.file_path}, start_time={self.start_time}, end_time={self.end_time}, duration={self.duration}, width={self.width}, height={self.height})" audio_codec = "aac" # Docker 里的 ffmpeg/AAC 组合在默认配置下更容易出现音频质量波动, # 这里显式抬高音频码率,避免成片阶段因为默认值过低而引入明显失真。 audio_bitrate = "192k" video_codec = "libx264" fps = 30 _BGM_EXTENSIONS = (".mp3",) def get_ffmpeg_binary(): # 优先复用用户在 config.toml / 环境变量里显式指定的 ffmpeg,可避免 # Windows 便携包、Docker、自定义安装目录等场景下 PATH 不一致。 configured_ffmpeg = os.environ.get("IMAGEIO_FFMPEG_EXE") if configured_ffmpeg: return configured_ffmpeg system_ffmpeg = shutil.which("ffmpeg") if system_ffmpeg: return system_ffmpeg try: import imageio_ffmpeg bundled_ffmpeg = imageio_ffmpeg.get_ffmpeg_exe() if bundled_ffmpeg: return bundled_ffmpeg except Exception as exc: logger.warning(f"failed to resolve bundled ffmpeg binary: {str(exc)}") return "ffmpeg" def _escape_ffmpeg_concat_path(file_path: str) -> str: # concat demuxer 使用单引号包裹路径,路径中的单引号需要先转义。 return file_path.replace("'", "'\\''") def concat_video_clips_with_ffmpeg( clip_files: List[str], output_file: str, threads: int, output_dir: str ): concat_list_file = os.path.join(output_dir, "ffmpeg-concat-list.txt") with open(concat_list_file, "w", encoding="utf-8") as fp: for clip_file in clip_files: absolute_path = os.path.abspath(clip_file) fp.write(f"file '{_escape_ffmpeg_concat_path(absolute_path)}'\n") command = [ get_ffmpeg_binary(), "-y", "-f", "concat", "-safe", "0", "-i", concat_list_file, "-c:v", video_codec, "-threads", str(threads or 2), "-pix_fmt", "yuv420p", output_file, ] try: # 使用 ffmpeg 只做一次串联与编码,避免 MoviePy 逐段合并时反复重编码, # 从而降低画质劣化与颜色偏移风险。 result = subprocess.run( command, capture_output=True, text=True, check=False, ) if result.returncode != 0: error_message = (result.stderr or result.stdout or "").strip() raise RuntimeError(error_message or "ffmpeg concat failed") finally: delete_files(concat_list_file) def _sanitize_image_file(image_path: str) -> str: # 某些本地图片虽然能被 Pillow 打开,但会因为损坏的 EXIF/eXIf 元数据导致 # ImageClip 在解析阶段直接抛异常。这里重新导出一份“干净图片”,把坏元数据剥离掉。 image_root, _ = os.path.splitext(image_path) sanitized_path = f"{image_root}.sanitized.png" with Image.open(image_path) as image: image.load() # 统一导出为 PNG,避免 JPEG/PNG 不同元数据路径继续把坏块带过去。 cleaned_image = Image.new(image.mode, image.size) cleaned_image.putdata(list(image.getdata())) cleaned_image.save(sanitized_path) return sanitized_path def _open_image_clip_with_fallback(image_path: str): # 优先直接打开原始图片;如果因为损坏元数据失败,再尝试生成无元数据副本。 try: return ImageClip(image_path), image_path except Exception as exc: logger.warning( f"failed to open image directly, trying sanitized copy: {image_path}, error: {str(exc)}" ) sanitized_path = _sanitize_image_file(image_path) return ImageClip(sanitized_path), sanitized_path def _open_video_clip_quietly(video_path: str, audio: bool = False) -> VideoFileClip: """ 安静地打开视频文件,避免 MoviePy 2.1.x 把 ffmpeg 探测信息直接打印到 stdout。 背景: 当前依赖版本的 `FFMPEG_VideoReader` 内部存在 `print(self.infos)` 和 `print(ffmpeg command)`,读取无音轨的中间视频时会输出 `audio_found: False`。这只是输入素材 metadata,不代表最终成片没有音频, 但会误导 WebUI/终端用户以为生成失败。 实现: 1. 只在打开 VideoFileClip 的短窗口内重定向 stdout; 2. 默认 `audio=False`,因为项目视频素材阶段不需要保留素材原声, 最终音频会在 `generate_video()` 阶段统一挂载; 3. 如果依赖库确实输出了内容,降级为 debug 日志,便于必要时排查。 """ captured_stdout = io.StringIO() with redirect_stdout(captured_stdout): print("video_path", video_path) clip = VideoFileClip(video_path, audio=audio) moviepy_stdout = captured_stdout.getvalue().strip() if moviepy_stdout: logger.debug( "suppressed MoviePy video reader stdout for " f"{video_path}, chars: {len(moviepy_stdout)}" ) return clip def close_clip(clip): if clip is None: return try: # close main resources if hasattr(clip, 'reader') and clip.reader is not None: clip.reader.close() # close audio resources if hasattr(clip, 'audio') and clip.audio is not None: if hasattr(clip.audio, 'reader') and clip.audio.reader is not None: clip.audio.reader.close() del clip.audio # close mask resources if hasattr(clip, 'mask') and clip.mask is not None: if hasattr(clip.mask, 'reader') and clip.mask.reader is not None: clip.mask.reader.close() del clip.mask # handle child clips in composite clips if hasattr(clip, 'clips') and clip.clips: for child_clip in clip.clips: if child_clip is not clip: # avoid possible circular references close_clip(child_clip) # clear clip list if hasattr(clip, 'clips'): clip.clips = [] except Exception as e: logger.error(f"failed to close clip: {str(e)}") del clip gc.collect() def delete_files(files: List[str] | str): if isinstance(files, str): files = [files] for file in files: try: os.remove(file) except Exception as e: logger.debug(f"failed to delete file {file}: {str(e)}") def _resolve_bgm_file_path(song_dir: str, bgm_file: str) -> str: # 背景音乐只允许读取 resource/songs 目录内的文件,避免用户输入任意路径后 # 被 MoviePy 打开。这里兼容两种常见输入: # 1. output000.mp3:来自 BGM 列表或用户只填写文件名 # 2. ./resource/songs/output000.mp3:用户按项目目录结构填写的相对路径 # 两种写法最终都会再次通过 resource/songs 白名单校验,不能绕过目录限制。 try: return file_security.resolve_path_within_directory(song_dir, bgm_file) except ValueError as song_dir_exc: if os.path.isabs(bgm_file): raise song_dir_exc project_relative_file = os.path.join(utils.root_dir(), bgm_file) try: return file_security.resolve_path_within_directory( song_dir, project_relative_file ) except ValueError as root_dir_exc: raise ValueError(str(root_dir_exc)) from song_dir_exc def get_bgm_file(bgm_type: str = "random", bgm_file: str = ""): if not bgm_type: return "" if bgm_file: song_dir = utils.song_dir() try: resolved_bgm_file = _resolve_bgm_file_path(song_dir, bgm_file) except ValueError as exc: # API 请求里的 bgm_file 来自用户输入,不能直接把任意绝对路径交给 # MoviePy 打开。这里强制限制到 resource/songs 目录,阻止读取 # /etc/passwd、配置文件、密钥等非背景音乐文件。 logger.warning( f"reject unsafe bgm file: {bgm_file}, song_dir: {song_dir}, error: {str(exc)}" ) return "" if not resolved_bgm_file.lower().endswith(_BGM_EXTENSIONS): logger.warning(f"reject unsupported bgm file extension: {resolved_bgm_file}") return "" return resolved_bgm_file if bgm_type == "random": suffix = "*.mp3" song_dir = utils.song_dir() files = glob.glob(os.path.join(song_dir, suffix)) # 当背景音乐目录为空时,直接回退为“不使用 BGM”,避免 random.choice([]) 抛异常。 if not files: logger.warning(f"no bgm files found in song directory: {song_dir}") return "" return random.choice(files) return "" def combine_videos( combined_video_path: str, video_paths: List[str], audio_file: str, video_aspect: VideoAspect = VideoAspect.portrait, video_concat_mode: VideoConcatMode = VideoConcatMode.random, video_transition_mode: VideoTransitionMode = None, max_clip_duration: int = 5, threads: int = 2, ) -> str: audio_clip = AudioFileClip(audio_file) try: # 这里只需要读取旁白音频时长来决定素材视频拼接长度;后续不会再使用 # audio_clip。读取完成后立即关闭,避免早退或异常路径泄漏文件句柄。 audio_duration = audio_clip.duration finally: close_clip(audio_clip) logger.info(f"audio duration: {audio_duration} seconds") logger.info(f"maximum clip duration: {max_clip_duration} seconds") # 兼容 API 直接调用时未传转场模式的情况,避免后续访问 .value 时崩溃。 transition_value = getattr(video_transition_mode, "value", video_transition_mode) output_dir = os.path.dirname(combined_video_path) aspect = VideoAspect(video_aspect) video_width, video_height = aspect.to_resolution() processed_clips = [] subclipped_items = [] video_duration = 0 for video_path in video_paths: clip = _open_video_clip_quietly(video_path) clip_duration = clip.duration clip_w, clip_h = clip.size close_clip(clip) start_time = 0 while start_time < clip_duration: end_time = min(start_time + max_clip_duration, clip_duration) # 保留所有有效分段。 # 这样既不会丢掉“整段视频本身就短于 max_clip_duration”的素材, # 也不会吞掉长视频最后剩下的一小段尾部内容。 if end_time > start_time: subclipped_items.append( SubClippedVideoClip( file_path=video_path, start_time=start_time, end_time=end_time, width=clip_w, height=clip_h, ) ) start_time = end_time if video_concat_mode.value == VideoConcatMode.sequential.value: break # random subclipped_items order if video_concat_mode.value == VideoConcatMode.random.value: random.shuffle(subclipped_items) logger.debug(f"total subclipped items: {len(subclipped_items)}") # Add downloaded clips over and over until the duration of the audio (max_duration) has been reached for i, subclipped_item in enumerate(subclipped_items): if video_duration > audio_duration: break logger.debug( f"processing clip {i + 1}: {subclipped_item.width}x{subclipped_item.height}, current duration: {video_duration:.2f}s, remaining: {audio_duration - video_duration:.2f}s") try: clip = _open_video_clip_quietly(subclipped_item.file_path).subclipped( subclipped_item.start_time, subclipped_item.end_time ) clip_duration = clip.duration # Not all videos are same size, so we need to resize them clip_w, clip_h = clip.size if clip_w != video_width or clip_h != video_height: clip_ratio = clip.w / clip.h video_ratio = video_width / video_height logger.debug( f"resizing clip, source: {clip_w}x{clip_h}, ratio: {clip_ratio:.2f}, target: {video_width}x{video_height}, ratio: {video_ratio:.2f}") if clip_ratio == video_ratio: clip = clip.resized(new_size=(video_width, video_height)) else: if clip_ratio > video_ratio: scale_factor = video_width / clip_w else: scale_factor = video_height / clip_h new_width = int(clip_w * scale_factor) new_height = int(clip_h * scale_factor) background = ColorClip(size=(video_width, video_height), color=(0, 0, 0)).with_duration( clip_duration) clip_resized = clip.resized(new_size=(new_width, new_height)).with_position("center") clip = CompositeVideoClip([background, clip_resized]) shuffle_side = random.choice(["left", "right", "top", "bottom"]) if transition_value in (None, VideoTransitionMode.none.value): clip = clip elif transition_value == VideoTransitionMode.fade_in.value: clip = video_effects.fadein_transition(clip, 1) elif transition_value == VideoTransitionMode.fade_out.value: clip = video_effects.fadeout_transition(clip, 1) elif transition_value == VideoTransitionMode.slide_in.value: clip = video_effects.slidein_transition(clip, 1, shuffle_side) elif transition_value == VideoTransitionMode.slide_out.value: clip = video_effects.slideout_transition(clip, 1, shuffle_side) elif transition_value == VideoTransitionMode.shuffle.value: transition_funcs = [ lambda c: video_effects.fadein_transition(c, 1), lambda c: video_effects.fadeout_transition(c, 1), lambda c: video_effects.slidein_transition(c, 1, shuffle_side), lambda c: video_effects.slideout_transition(c, 1, shuffle_side), ] shuffle_transition = random.choice(transition_funcs) clip = shuffle_transition(clip) if clip.duration > max_clip_duration: clip = clip.subclipped(0, max_clip_duration) # wirte clip to temp file clip_file = f"{output_dir}/temp-clip-{i + 1}.mp4" clip.write_videofile(clip_file, logger=None, fps=fps, codec=video_codec) # Store clip duration before closing clip_duration_saved = clip.duration close_clip(clip) processed_clips.append( SubClippedVideoClip(file_path=clip_file, duration=clip_duration_saved, width=clip_w, height=clip_h)) video_duration += clip_duration_saved except Exception as e: logger.error(f"failed to process clip: {str(e)}") # loop processed clips until the video duration matches or exceeds the audio duration. if video_duration < audio_duration: logger.warning( f"video duration ({video_duration:.2f}s) is shorter than audio duration ({audio_duration:.2f}s), looping clips to match audio length.") base_clips = processed_clips.copy() for clip in itertools.cycle(base_clips): if video_duration >= audio_duration: break processed_clips.append(clip) video_duration += clip.duration logger.info( f"video duration: {video_duration:.2f}s, audio duration: {audio_duration:.2f}s, looped {len(processed_clips) - len(base_clips)} clips") # merge video clips progressively, avoid loading all videos at once to avoid memory overflow logger.info("starting clip merging process") if not processed_clips: logger.warning("no clips available for merging") return combined_video_path # if there is only one clip, use it directly if len(processed_clips) == 1: logger.info("using single clip directly") shutil.copy(processed_clips[0].file_path, combined_video_path) delete_files([processed_clips[0].file_path]) logger.info("video combining completed") return combined_video_path clip_files = [clip.file_path for clip in processed_clips] logger.info(f"concatenating {len(clip_files)} clips with ffmpeg") concat_video_clips_with_ffmpeg( clip_files=clip_files, output_file=combined_video_path, threads=threads, output_dir=output_dir, ) # clean temp files delete_files(clip_files) logger.info("video combining completed") return combined_video_path def wrap_text(text, max_width, font="Arial", fontsize=60): # 字幕换行必须在真正创建 TextClip 前完成,否则 MoviePy 只会按原始文本 # 计算渲染区域。这里用 PIL 按当前字体和字号测量宽度,确保每一行都尽量 # 控制在视频可用宽度内,避免大字号或中文长句直接溢出画面。 font = ImageFont.truetype(font, fontsize) max_width = int(max_width) def get_text_size(inner_text): inner_text = inner_text.strip() if not inner_text: return 0, fontsize left, top, right, bottom = font.getbbox(inner_text) return right - left, bottom - top width, height = get_text_size(text) if width <= max_width: return text, height def split_long_token(token): # 当一个 token 本身就超宽时(常见于中文无空格长句,或英文超长单词), # 退化为字符级拆分。关键点是:检测到 candidate 超宽时,先提交上一个 # 仍然合法的 current,再把当前字符放入下一行,不能把超宽字符塞回上一行。 lines = [] current = "" for char in token: candidate = f"{current}{char}" candidate_width, _ = get_text_size(candidate) if candidate_width <= max_width or not current: current = candidate continue lines.append(current) current = char if current: lines.append(current) return lines lines = [] current = "" words = text.split(" ") for word in words: candidate = f"{current} {word}".strip() if current else word candidate_width, _ = get_text_size(candidate) if candidate_width <= max_width: current = candidate continue if current: lines.append(current) word_width, _ = get_text_size(word) if word_width <= max_width: current = word else: lines.extend(split_long_token(word)) current = "" if current: lines.append(current) result = "\n".join(line.strip() for line in lines if line.strip()).strip() height = len(lines) * height return result, height def generate_video( video_path: str, audio_path: str, subtitle_path: str, output_file: str, params: VideoParams, ): aspect = VideoAspect(params.video_aspect) video_width, video_height = aspect.to_resolution() logger.info(f"generating video: {video_width} x {video_height}") logger.info(f" ① video: {video_path}") logger.info(f" ② audio: {audio_path}") logger.info(f" ③ subtitle: {subtitle_path}") logger.info(f" ④ output: {output_file}") # https://github.com/harry0703/MoneyPrinterTurbo/issues/217 # PermissionError: [WinError 32] The process cannot access the file because it is being used by another process: 'final-1.mp4.tempTEMP_MPY_wvf_snd.mp3' # write into the same directory as the output file output_dir = os.path.dirname(output_file) font_path = "" if params.subtitle_enabled: if not params.font_name: params.font_name = "STHeitiMedium.ttc" font_path = os.path.join(utils.font_dir(), params.font_name) if os.name == "nt": font_path = font_path.replace("\\", "/") logger.info(f" ⑤ font: {font_path}") def resolve_subtitle_background_color(): # 兼容历史参数:API 里 `text_background_color` 既可能是布尔值, # 也可能是实际颜色字符串。统一在这里归一化,避免把 True/False # 直接传给 TextClip 后出现不可预期的渲染结果。 if isinstance(params.text_background_color, bool): return "#000000" if params.text_background_color else None return params.text_background_color def create_text_clip(subtitle_item): params.font_size = int(params.font_size) params.stroke_width = int(params.stroke_width) phrase = subtitle_item[1] max_width = video_width * 0.9 wrapped_txt, txt_height = wrap_text( phrase, max_width=max_width, font=font_path, fontsize=params.font_size ) interline = int(params.font_size * 0.25) line_count = wrapped_txt.count("\n") + 1 vertical_padding = int(params.font_size * 0.35) # MoviePy 在 `method=label` 下会自动收缩文本框高度,遇到多行字幕、 # 描边或背景色时,容易把最后一行的下半部分裁掉。这里显式传入 # 一个更保守的高度,把行间距和额外上下留白一并算进去,保证字幕 # 背景框与文字本身都能完整渲染出来。 size = ( int(max_width), int(txt_height + vertical_padding + (interline * line_count)), ) _clip = TextClip( text=wrapped_txt, font=font_path, font_size=params.font_size, color=params.text_fore_color, bg_color=resolve_subtitle_background_color(), stroke_color=params.stroke_color, stroke_width=params.stroke_width, interline=interline, size=size, text_align="center", ) duration = subtitle_item[0][1] - subtitle_item[0][0] _clip = _clip.with_start(subtitle_item[0][0]) _clip = _clip.with_end(subtitle_item[0][1]) _clip = _clip.with_duration(duration) if params.subtitle_position == "bottom": _clip = _clip.with_position(("center", video_height * 0.95 - _clip.h)) elif params.subtitle_position == "top": _clip = _clip.with_position(("center", video_height * 0.05)) elif params.subtitle_position == "custom": # Ensure the subtitle is fully within the screen bounds margin = 10 # Additional margin, in pixels max_y = video_height - _clip.h - margin min_y = margin custom_y = (video_height - _clip.h) * (params.custom_position / 100) custom_y = max( min_y, min(custom_y, max_y) ) # Constrain the y value within the valid range _clip = _clip.with_position(("center", custom_y)) else: # center _clip = _clip.with_position(("center", "center")) return _clip video_clip = _open_video_clip_quietly(video_path) audio_clip = AudioFileClip(audio_path).with_effects( [afx.MultiplyVolume(params.voice_volume)] ) def make_textclip(text): return TextClip( text=text, font=font_path, font_size=params.font_size, ) if subtitle_path and os.path.exists(subtitle_path): sub = SubtitlesClip( subtitles=subtitle_path, encoding="utf-8", make_textclip=make_textclip ) text_clips = [] for item in sub.subtitles: clip = create_text_clip(subtitle_item=item) text_clips.append(clip) video_clip = CompositeVideoClip([video_clip, *text_clips]) bgm_file = get_bgm_file(bgm_type=params.bgm_type, bgm_file=params.bgm_file) if bgm_file: try: bgm_clip = AudioFileClip(bgm_file).with_effects( [ afx.MultiplyVolume(params.bgm_volume), afx.AudioFadeOut(3), afx.AudioLoop(duration=video_clip.duration), ] ) audio_clip = CompositeAudioClip([audio_clip, bgm_clip]) except Exception as e: logger.error(f"failed to add bgm: {str(e)}") video_clip = video_clip.with_audio(audio_clip) # 显式沿用输入音频的采样率;如果取不到,再回退到 MoviePy 默认的 44100Hz。 # 这样可以减少不同运行环境,尤其是 Docker 环境中再次重采样带来的音质波动。 output_audio_fps = int(getattr(audio_clip, "fps", 0) or 44100) video_clip.write_videofile( output_file, audio_codec=audio_codec, audio_fps=output_audio_fps, audio_bitrate=audio_bitrate, temp_audiofile_path=output_dir, threads=params.n_threads or 2, logger=None, fps=fps, ) video_clip.close() del video_clip def preprocess_video(materials: List[MaterialInfo], clip_duration=4): # WebUI 在某些二次生成场景下可能传入空素材列表,这里直接返回空结果,避免抛出 NoneType 异常。 if not materials: return [] # 仅返回通过预处理校验的素材,避免低分辨率图片继续进入后续的视频合成流程。 valid_materials = [] local_videos_dir = utils.storage_dir("local_videos", create=True) for material in materials: if not material.url: continue try: material_source_path = file_security.resolve_path_within_directory( local_videos_dir, material.url ) except ValueError as exc: # local video_source 的素材路径来自 API 参数,必须限制在专用素材目录。 # 允许用户传文件名,也兼容历史返回的绝对路径,但不允许逃逸到系统 # 其他目录,避免任意文件读取或通过 MoviePy 探测本地敏感文件。 logger.warning( f"skip unsafe local material: {material.url}, " f"local_videos_dir: {local_videos_dir}, error: {str(exc)}" ) continue ext = utils.parse_extension(material_source_path) try: # 图片素材直接按图片方式读取,避免先走 VideoFileClip 误判后触发不稳定的回退分支。 if ext in const.FILE_TYPE_IMAGES: clip, material_source_path = _open_image_clip_with_fallback( material_source_path ) else: clip = _open_video_clip_quietly(material_source_path) except Exception: # 非标准扩展名或探测失败时再回退到图片模式,兼容历史上直接传本地图片路径的情况。 try: clip, material_source_path = _open_image_clip_with_fallback( material_source_path ) except Exception as exc: logger.warning( f"skip unreadable local material: {material.url}, error: {str(exc)}" ) continue try: width = clip.size[0] height = clip.size[1] if width < 480 or height < 480: logger.warning(f"low resolution material: {width}x{height}, minimum 480x480 required") # 探测到低分辨率素材后立即关闭资源,并且不要把该素材返回给后续流程。 close_clip(clip) continue if ext in const.FILE_TYPE_IMAGES: logger.info(f"processing image: {material_source_path}") # 探测尺寸时已经打开过一次素材,这里先释放探测句柄,再重新创建用于导出的图片 clip。 close_clip(clip) # Create an image clip and set its duration to 3 seconds clip = ( ImageClip(material_source_path) .with_duration(clip_duration) .with_position("center") ) # Apply a zoom effect using the resize method. # A lambda function is used to make the zoom effect dynamic over time. # The zoom effect starts from the original size and gradually scales up to 120%. # t represents the current time, and clip.duration is the total duration of the clip (3 seconds). # Note: 1 represents 100% size, so 1.2 represents 120% size. zoom_clip = clip.resized( lambda t: 1 + (clip_duration * 0.03) * (t / clip.duration) ) # Optionally, create a composite video clip containing the zoomed clip. # This is useful when you want to add other elements to the video. final_clip = CompositeVideoClip([zoom_clip]) # Output the video to a file. video_file = f"{material_source_path}.mp4" final_clip.write_videofile(video_file, fps=30, logger=None) close_clip(clip) close_clip(final_clip) material.url = video_file logger.success(f"image processed: {video_file}") else: # 普通视频素材只需要读取尺寸做校验,校验完成后立即释放句柄即可。 close_clip(clip) except Exception: close_clip(clip) raise valid_materials.append(material) return valid_materials