diff --git a/apps/api/app/services/video_compose_service.py b/apps/api/app/services/video_compose_service.py index 7ff2e7559..06b88f2a2 100755 --- a/apps/api/app/services/video_compose_service.py +++ b/apps/api/app/services/video_compose_service.py @@ -39,6 +39,9 @@ from packages.domain.video_filter_builder import ( ) from packages.domain.video_filter_builder import build_concat_filter as _build_concat_filter_func from packages.domain.video_filter_builder import build_filter_complex as _build_filter_complex +from packages.domain.video_filter_builder import ( + build_title_drawtext_filter, +) from packages.domain.video_filter_builder import build_xfade_filter as _build_xfade_filter_func from packages.domain.video_filter_builder import chain_filters as _chain_filters_func from packages.domain.video_filter_builder import has_audio as _has_audio_func @@ -248,6 +251,27 @@ class VideoComposeService: transitions=[c.transition_effect for c in ready_clips], ) + # ── #1789 标题 drawtext 滤镜叠加 ── + # 从 plan.config 读取 title_config,生成 drawtext 滤镜链入 filter_complex + title_cfg = (plan.config or {}).get("title", {}) or {} + if not isinstance(title_cfg, dict): + title_cfg = {} + # 同时兼容 plan.config["title_config"](API 回写路径) + if not title_cfg.get("text") and not title_cfg.get("content"): + title_cfg_alt = (plan.config or {}).get("title_config", {}) or {} + if isinstance(title_cfg_alt, dict) and (title_cfg_alt.get("text") or title_cfg_alt.get("content")): + title_cfg = title_cfg_alt + drawtext_filter = build_title_drawtext_filter(title_cfg, output_width, output_height) + if drawtext_filter: + # 将最终输出标签从 [outv] 改为 [composed],再链入 drawtext → [outv] + filter_complex = filter_complex.replace("[outv]", "[composed]") + filter_complex += f";[composed]{drawtext_filter}[outv]" + logger.info( + "[#1789] 标题 drawtext 滤镜已注入: plan_id=%s text=%s", + plan_id, + (title_cfg.get("text") or title_cfg.get("content") or "")[:30], + ) + # 构建完整命令 command: list[str] = ["ffmpeg", "-y"] diff --git a/packages/domain/video_filter_builder.py b/packages/domain/video_filter_builder.py index 7cbfc36b6..2915893fa 100755 --- a/packages/domain/video_filter_builder.py +++ b/packages/domain/video_filter_builder.py @@ -14,7 +14,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from packages.domain.template_clip_config import TransitionEffect @@ -373,3 +373,193 @@ def _append_audio_concat(parts: list[str], clip_chains: list[ClipFilterChain]) - # concat 滤镜(使用 audio_label 作为输入) audio_inputs = "".join(f"[{c.audio_label}]" for c in audio_chains) parts.append(f"{audio_inputs}concat=n={len(audio_chains)}:v=0:a=1[outa]") + + +# ── 标题 drawtext 滤镜构建(#1789)───────────────────────────────────────────── + +# drawtext 字体搜索路径:按优先级列出常见安装位置 +# 服务器使用 Noto Sans SC(思源黑体)作为默认字体 +DRAWTEXT_FONT_SEARCH_PATHS: list[str] = [ + "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/google-noto-cjk/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/truetype/noto/NotoSansSC-Regular.ttf", + "/usr/share/fonts/noto/NotoSansSC-Regular.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", +] + +# 前端字体名 → drawtext 字体搜索关键字 +DRAWTEXT_FONT_MAP: dict[str, str] = { + "思源黑体": "NotoSansCJK", + "思源宋体": "NotoSerifCJK", + "苹方": "NotoSansCJK", + "PingFang": "NotoSansCJK", + "微软雅黑": "NotoSansCJK", + "楷体": "NotoSerifCJK", + "华康俪金黑": "NotoSansCJK", +} + + +def _escape_drawtext_text(text: str) -> str: + """转义 drawtext 特殊字符。 + + FFmpeg drawtext 要求转义: + - \\ → \\\\ + - ' → \\\\' + - : → \\\\: + - % → %%(drawtext 中 % 是时间码特殊字符) + """ + result = text.replace("\\", "\\\\\\\\") + result = result.replace("'", "\\\\'") + result = result.replace(":", "\\\\:") + result = result.replace("%", "%%") + return result + + +def _resolve_font_path(font_name: str) -> str: + """解析字体名到服务器实际字体文件路径。 + + 查找策略: + 1. 通过 DRAWTEXT_FONT_MAP 映射前端字体名到服务器关键字 + 2. 在 DRAWTEXT_FONT_SEARCH_PATHS 中查找匹配路径 + 3. 未找到则返回空字符串(drawtext 使用内置默认字体) + """ + keyword = DRAWTEXT_FONT_MAP.get(font_name, font_name) + import os + + for path in DRAWTEXT_FONT_SEARCH_PATHS: + if keyword.lower() in path.lower() and os.path.isfile(path): + return path + # fallback:遍历搜索任意可用字体 + for path in DRAWTEXT_FONT_SEARCH_PATHS: + if os.path.isfile(path): + return path + return "" + + +def build_title_drawtext_filter( + title_config: dict[str, Any], + output_width: int = DEFAULT_OUTPUT_WIDTH, + output_height: int = DEFAULT_OUTPUT_HEIGHT, +) -> str | None: + """从 title_config 生成 FFmpeg drawtext 滤镜字符串。 + + 支持前端 TitleSettings 的全部参数: + - text / 标题文字 + - font / 字体名 + - font_size / 字号 + - font_color / 颜色(#RRGGBB) + - position / 位置(top / center / bottom / custom) + - bold / 粗体 + - stroke / 描边 + - shadow / 阴影 + - pos_x, pos_y / 自由位置坐标 + + Args: + title_config: 标题配置 dict(来自 plan.config["title"]) + output_width: 输出视频宽度 + output_height: 输出视频高度 + + Returns: + drawtext 滤镜字符串;标题为空或 disabled 时返回 None + """ + if not title_config or not isinstance(title_config, dict): + return None + + # 字段名归一化:兼容 content/text、font_preset/font 两套命名 + text = (title_config.get("text") or title_config.get("content") or "").strip() + if not text: + return None + + enabled = title_config.get("enabled", True) + if not enabled: + return None + + # ── 样式参数 ── + font_name = title_config.get("font") or title_config.get("font_preset") or "思源黑体" + font_size = int(title_config.get("font_size") or title_config.get("size") or 36) + font_color = title_config.get("font_color") or title_config.get("color") or "#ffffff" + # 去掉 # 前缀(drawtext 用纯 hex 或颜色名) + if font_color.startswith("#"): + font_color = font_color[1:] + + position = title_config.get("position", "top") + bold = bool(title_config.get("bold", True)) + stroke = title_config.get("stroke") + shadow = title_config.get("shadow") + + # ── 构建 drawtext 参数 ── + params: list[str] = [] + + # 字体文件 + font_path = _resolve_font_path(font_name) + if font_path: + escaped_path = font_path.replace("\\", "\\\\").replace(":", "\\\\:").replace("'", "\\\\'") + params.append(f"fontfile='{escaped_path}'") + + # 文字内容 + params.append(f"text='{_escape_drawtext_text(text)}'") + + # 字号 & 颜色 + params.append(f"fontsize={font_size}") + params.append(f"fontcolor={font_color}") + + # 粗体:bold 在 drawtext 中通过 font 的 Bold 变体实现 + # 若字体有 Bold 变体可用 fontfont=bold;否则通过 borderw 模拟 + if bold: + # 使用 font 参数尝试加载 Bold 变体(Noto Sans SC 有 Bold 变体文件) + params.append("font=bold") + + # 描边(borderw 需要 libfreetype 支持) + if stroke: + if isinstance(stroke, bool): + border_width = 2 + border_color = "black" + elif isinstance(stroke, dict): + border_width = int(stroke.get("width", 2)) if stroke.get("enabled", True) else 0 + border_color = (stroke.get("color") or "#000000").lstrip("#") + else: + border_width = 0 + border_color = "black" + if border_width > 0: + params.append(f"borderw={border_width}") + params.append(f"bordercolor={border_color}") + + # 阴影(shadowcolor + shadowx/y) + if shadow: + if isinstance(shadow, bool): + params.append("shadowcolor=black") + params.append("shadowx=2") + params.append("shadowy=2") + elif isinstance(shadow, dict): + if shadow.get("enabled", True): + params.append(f"shadowcolor={(shadow.get('color') or '#000000').lstrip('#')}") + params.append(f"shadowx={int(shadow.get('offset_x', 2))}") + params.append(f"shadowy={int(shadow.get('offset_y', 2))}") + + # ── 位置计算 ── + # 优先使用自定义坐标 pos_x / pos_y + pos_x = title_config.get("pos_x") + pos_y = title_config.get("pos_y") + if ( + position == "custom" + and isinstance(pos_x, (int, float)) + and isinstance(pos_y, (int, float)) + and not isinstance(pos_x, bool) + and not isinstance(pos_y, bool) + ): + params.append(f"x={int(pos_x)}") + params.append(f"y={int(pos_y)}") + else: + # 三档预设位置:top / center / bottom + # x 始终水平居中:(w-text_w)/2 + params.append("x=(w-text_w)/2") + if position == "center": + params.append("y=(h-text_h)/2") + elif position == "bottom": + params.append("y=h-text_h-50") + else: + # top(默认) + params.append("y=50") + + return "drawtext=" + ":".join(params) diff --git a/tests/unit/test_video_compose_service.py b/tests/unit/test_video_compose_service.py index 7bb2a24db..bda6ea9b9 100755 --- a/tests/unit/test_video_compose_service.py +++ b/tests/unit/test_video_compose_service.py @@ -8,6 +8,7 @@ from __future__ import annotations import sys from pathlib import Path from unittest import TestCase +from unittest.mock import patch # 修正 import 路径 sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api")) @@ -62,13 +63,14 @@ class _StubPlan: self, plan_id: str = "plan-1", status: EditPlanStatus = EditPlanStatus.EDITING, + config: dict | None = None, ): self.id = plan_id self.template_id = "tpl-1" self.name = "测试计划" self.status = status self.total_duration = 0.0 - self.config = {} + self.config = config or {} # ── Stub 仓储 ───────────────────────────────────────────────────────────────── @@ -712,6 +714,65 @@ class TestHasAudioTitleSubtitleFix(TestCase): self.assertEqual(chain.audio_label, "a0") +# ── #1789 标题 drawtext 集成测试 ────────────────────────────────────────────── + + +class TestComposeCommandTitleDrawtext(TestCase): + """build_compose_command 中标题 drawtext 集成测试。""" + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_title_config_injected(self, mock_font): + """plan.config 有 title 时,filter_complex 包含 drawtext。""" + mock_font.return_value = "" + plan = _StubPlan(config={"title": {"text": "测试标题", "font_size": 48, "position": "top"}}) + clips = [_make_ready_clip(plan_id=plan.id)] + svc = _make_service(plan, clips) + cmd = svc.build_compose_command(plan.id, "/tmp/out.mp4") + + self.assertIn("drawtext=", cmd.filter_complex) + self.assertIn("[composed]", cmd.filter_complex) + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_title_config_alt_key(self, mock_font): + """plan.config['title'] 无文本时回退到 title_config。""" + mock_font.return_value = "" + plan = _StubPlan(config={"title": {}, "title_config": {"text": "备用标题", "font_size": 36}}) + clips = [_make_ready_clip(plan_id=plan.id)] + svc = _make_service(plan, clips) + cmd = svc.build_compose_command(plan.id, "/tmp/out.mp4") + + self.assertIn("drawtext=", cmd.filter_complex) + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_no_title_no_drawtext(self, mock_font): + """无标题配置时,filter_complex 不包含 drawtext。""" + mock_font.return_value = "" + plan = _StubPlan(config={}) + clips = [_make_ready_clip(plan_id=plan.id)] + svc = _make_service(plan, clips) + cmd = svc.build_compose_command(plan.id, "/tmp/out.mp4") + + self.assertNotIn("drawtext=", cmd.filter_complex) + + def test_title_config_not_dict(self): + """title config 为非 dict 值时不崩溃。""" + plan = _StubPlan(config={"title": "not a dict"}) + clips = [_make_ready_clip(plan_id=plan.id)] + svc = _make_service(plan, clips) + cmd = svc.build_compose_command(plan.id, "/tmp/out.mp4") + self.assertIsNotNone(cmd) + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_title_content_field(self, mock_font): + """title config 使用 content 字段(前端 TitleConfig 命名)。""" + mock_font.return_value = "" + plan = _StubPlan(config={"title": {"content": "内容标题", "font_size": 36}}) + clips = [_make_ready_clip(plan_id=plan.id)] + svc = _make_service(plan, clips) + cmd = svc.build_compose_command(plan.id, "/tmp/out.mp4") + self.assertIn("drawtext=", cmd.filter_complex) + + if __name__ == "__main__": import unittest diff --git a/tests/unit/test_video_filter_builder.py b/tests/unit/test_video_filter_builder.py index bb984165e..0832dffdb 100755 --- a/tests/unit/test_video_filter_builder.py +++ b/tests/unit/test_video_filter_builder.py @@ -15,6 +15,7 @@ from __future__ import annotations import unittest from dataclasses import FrozenInstanceError +from unittest.mock import patch from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus from packages.domain.template_clip_config import TransitionEffect @@ -26,9 +27,12 @@ from packages.domain.video_filter_builder import ( DEFAULT_TRANSITION_DURATION, XFADE_TRANSITION_MAP, ClipFilterChain, + _escape_drawtext_text, + _resolve_font_path, build_clip_filter, build_concat_filter, build_filter_complex, + build_title_drawtext_filter, build_xfade_filter, chain_filters, has_audio, @@ -852,5 +856,265 @@ class TestEndToEndFilterBuilding(unittest.TestCase): self.assertNotIn("[a0]", filter_str) +# ── #1789 标题 drawtext 滤镜补充覆盖率 ────────────────────────────────────── + + +class TestEscapeDrawtextText(unittest.TestCase): + """直接测试转义函数,覆盖每一行。""" + + def test_backslash_escape(self): + result = _escape_drawtext_text("a\\b") + self.assertIn("\\\\", result) + + def test_single_quote_escape(self): + result = _escape_drawtext_text("it's") + self.assertIn("\\'", result) + + def test_colon_escape(self): + result = _escape_drawtext_text("a:b") + self.assertIn("\\:", result) + + def test_percent_escape(self): + result = _escape_drawtext_text("100%") + self.assertIn("%%", result) + + def test_all_special_chars_combined(self): + result = _escape_drawtext_text("\\':%") + self.assertIn("\\\\", result) + self.assertIn("\\'", result) + self.assertIn("\\:", result) + self.assertIn("%%", result) + + def test_no_special_chars(self): + result = _escape_drawtext_text("hello world") + self.assertEqual(result, "hello world") + + +class TestResolveFontPath(unittest.TestCase): + """测试字体路径解析逻辑。""" + + @patch("os.path.isfile") + def test_known_font_found(self, mock_isfile): + mock_isfile.side_effect = lambda p: "NotoSansCJK" in p + result = _resolve_font_path("思源黑体") + self.assertNotEqual(result, "") + self.assertIn("NotoSansCJK", result) + + @patch("os.path.isfile") + def test_unknown_font_fallback(self, mock_isfile): + mock_isfile.side_effect = lambda p: "DejaVu" in p + result = _resolve_font_path("UnknownFont") + self.assertIn("DejaVu", result) + + @patch("os.path.isfile") + def test_no_fonts_available(self, mock_isfile): + mock_isfile.return_value = False + result = _resolve_font_path("思源黑体") + self.assertEqual(result, "") + + @patch("os.path.isfile") + def test_passthrough_font_name(self, mock_isfile): + mock_isfile.side_effect = lambda p: "NotoSansCJK" in p + result = _resolve_font_path("NotoSansCJK") + self.assertNotEqual(result, "") + + @patch("os.path.isfile") + def test_font_search_first_match(self, mock_isfile): + mock_isfile.side_effect = lambda p: "opentype" in p + result = _resolve_font_path("思源黑体") + self.assertNotEqual(result, "") + self.assertIn("opentype", result) + + @patch("os.path.isfile") + def test_font_fallback_skips_nonexistent(self, mock_isfile): + mock_isfile.side_effect = lambda p: "DejaVu" in p + result = _resolve_font_path("不存在字体") + self.assertIn("DejaVu", result) + + +class TestDrawtextFontFileIncluded(unittest.TestCase): + """当字体文件存在时,fontfile 参数出现在输出中。""" + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_fontfile_in_output(self, mock_font): + mock_font.return_value = "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc" + result = build_title_drawtext_filter({"text": "标题"}) + self.assertIsNotNone(result) + self.assertIn("fontfile=", result) + self.assertIn("NotoSansCJK", result) + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_fontfile_escaped(self, mock_font): + mock_font.return_value = "/path/with:special'chars.ttf" + result = build_title_drawtext_filter({"text": "标题"}) + self.assertIsNotNone(result) + self.assertIn("fontfile=", result) + + +class TestDrawtextFontFileNotIncluded(unittest.TestCase): + """当字体文件不存在时,无 fontfile 参数。""" + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_no_fontfile(self, mock_font): + mock_font.return_value = "" + result = build_title_drawtext_filter({"text": "标题"}) + self.assertIsNotNone(result) + self.assertNotIn("fontfile=", result) + + +class TestDrawtextStrokeBranches(unittest.TestCase): + """stroke 各分支覆盖。""" + + def test_stroke_non_bool_non_dict(self): + result = build_title_drawtext_filter({"text": "标题", "stroke": "yes"}) + self.assertIsNotNone(result) + self.assertNotIn("borderw", result) + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_stroke_dict_default_color(self, mock_font): + mock_font.return_value = "" + result = build_title_drawtext_filter({"text": "标题", "stroke": {"width": 4}}) + self.assertIsNotNone(result) + self.assertIn("borderw=4", result) + self.assertIn("bordercolor=000000", result) + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_stroke_dict_enabled_false(self, mock_font): + mock_font.return_value = "" + result = build_title_drawtext_filter({"text": "标题", "stroke": {"enabled": False, "width": 5}}) + self.assertIsNotNone(result) + self.assertNotIn("borderw", result) + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_stroke_dict_custom_color(self, mock_font): + mock_font.return_value = "" + result = build_title_drawtext_filter({"text": "标题", "stroke": {"width": 2, "color": "#ff0000"}}) + self.assertIsNotNone(result) + self.assertIn("bordercolor=ff0000", result) + + +class TestDrawtextShadowBranches(unittest.TestCase): + """shadow 各分支覆盖。""" + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_shadow_dict_default_color(self, mock_font): + mock_font.return_value = "" + result = build_title_drawtext_filter({"text": "标题", "shadow": {"offset_x": 5, "offset_y": 5}}) + self.assertIsNotNone(result) + self.assertIn("shadowcolor=000000", result) + self.assertIn("shadowx=5", result) + self.assertIn("shadowy=5", result) + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_shadow_dict_disabled(self, mock_font): + mock_font.return_value = "" + result = build_title_drawtext_filter({"text": "标题", "shadow": {"enabled": False}}) + self.assertIsNotNone(result) + self.assertNotIn("shadowcolor", result) + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_shadow_dict_custom_color(self, mock_font): + mock_font.return_value = "" + result = build_title_drawtext_filter( + {"text": "标题", "shadow": {"color": "#555555", "offset_x": 1, "offset_y": 1}} + ) + self.assertIsNotNone(result) + self.assertIn("shadowcolor=555555", result) + + +class TestDrawtextBoldFalse(unittest.TestCase): + def test_bold_false(self): + result = build_title_drawtext_filter({"text": "标题", "bold": False}) + self.assertIsNotNone(result) + self.assertNotIn("font=bold", result) + + +class TestDrawtextPositionBranches(unittest.TestCase): + """位置相关分支覆盖。""" + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_position_top_explicit(self, mock_font): + mock_font.return_value = "" + result = build_title_drawtext_filter({"text": "标题", "position": "top"}) + self.assertIsNotNone(result) + self.assertIn("y=50", result) + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_position_center_explicit(self, mock_font): + mock_font.return_value = "" + result = build_title_drawtext_filter({"text": "标题", "position": "center"}) + self.assertIsNotNone(result) + self.assertIn("y=(h-text_h)/2", result) + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_position_bottom_explicit(self, mock_font): + mock_font.return_value = "" + result = build_title_drawtext_filter({"text": "标题", "position": "bottom"}) + self.assertIsNotNone(result) + self.assertIn("y=h-text_h-50", result) + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_position_custom_with_float_coords(self, mock_font): + mock_font.return_value = "" + result = build_title_drawtext_filter({"text": "标题", "position": "custom", "pos_x": 100.7, "pos_y": 200.3}) + self.assertIsNotNone(result) + self.assertIn("x=100", result) + self.assertIn("y=200", result) + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_position_custom_bool_coords_fallback(self, mock_font): + mock_font.return_value = "" + result = build_title_drawtext_filter({"text": "标题", "position": "custom", "pos_x": True, "pos_y": True}) + self.assertIsNotNone(result) + self.assertIn("x=(w-text_w)/2", result) + self.assertIn("y=50", result) + + +class TestDrawtextColorNoHash(unittest.TestCase): + def test_color_without_hash(self): + result = build_title_drawtext_filter({"text": "标题", "font_color": "red"}) + self.assertIsNotNone(result) + self.assertIn("fontcolor=red", result) + + +class TestDrawtextFieldNormalization(unittest.TestCase): + """字段归一化覆盖更多分支。""" + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_content_fallback(self, mock_font): + mock_font.return_value = "" + result = build_title_drawtext_filter({"content": "备用标题"}) + self.assertIsNotNone(result) + self.assertIn("备用标题", result) + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_font_preset_fallback(self, mock_font): + mock_font.return_value = "" + result = build_title_drawtext_filter({"text": "标题", "font_preset": "楷体"}) + self.assertIsNotNone(result) + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_size_fallback(self, mock_font): + mock_font.return_value = "" + result = build_title_drawtext_filter({"text": "标题", "size": 72}) + self.assertIsNotNone(result) + self.assertIn("fontsize=72", result) + + @patch("packages.domain.video_filter_builder._resolve_font_path") + def test_color_fallback(self, mock_font): + mock_font.return_value = "" + result = build_title_drawtext_filter({"text": "标题", "color": "#abcdef"}) + self.assertIsNotNone(result) + self.assertIn("fontcolor=abcdef", result) + + +class TestDrawtextNotDictConfig(unittest.TestCase): + def test_string_config_returns_none(self): + self.assertIsNone(build_title_drawtext_filter("not a dict")) + + def test_list_config_returns_none(self): + self.assertIsNone(build_title_drawtext_filter([1, 2, 3])) + + if __name__ == "__main__": unittest.main()