diff --git a/.gitignore b/.gitignore index da0e309..e93ab30 100644 --- a/.gitignore +++ b/.gitignore @@ -206,9 +206,13 @@ marimo/_static/ marimo/_lsp/ __marimo__/ +*/.* .* !.gitignore # ComfyUI-Simple-Iterator runtime state .iterator_state.json +# Test temp state +tests/.tmp/ +tests/.tmp_manual/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..fa3a63f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,20 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [0.1.0] - 2026-03-28 + +### Added +- Added persistent iterator state with scope isolation by node id and source key. +- Added `Iterator Load Image` with one-sample-per-run behavior and file name output. +- Added `Iterator Load Video Path` with one-sample-per-run behavior and file name output. +- Added `Iterator Load Text From Dir` and `Iterator Load Text From File`. +- Added loop modes: `loop`, `stop`, and `hold_last`. +- Added edge-triggered reset behavior (`False -> True` only). +- Added runtime state ignore rule for `.iterator_state.json`. +- Added minimal tests for sequence progression, stop mode, edge reset, and filename output behavior. + +### Changed +- Split text loading into two dedicated nodes and removed the combined text node. +- Updated defaults for image/video/text patterns and loop behavior to safer batch-processing defaults. +- Expanded inline node documentation and README usage details. diff --git a/README.md b/README.md index 851538c..a14c8f2 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,29 @@ # ComfyUI-Simple-Iterator -ComfyUI custom nodes for one-sample-per-run batch iteration. +ComfyUI custom nodes for one-sample-per-run iteration across image, video, and text sources. + +Language: **English** | [简体中文](./README.zh-CN.md) + +## Features + +- Persistent iterator cursor state (`.iterator_state.json`) +- Scope isolation by node id + source settings +- Edge-triggered reset (`False -> True`) +- Safe stop mode to prevent silent duplicate processing +- File name output with or without extension ## Common Semantics - `loop_mode` - - `loop`: 到末尾后回到第一个继续。 - - `stop`: 到末尾后下一次执行报错停止,避免重复处理。 - - `hold_last`: 到末尾后持续返回最后一个样本。 + - `loop`: wrap to the first item after the last item + - `stop`: raise an error when exhausted (recommended for batch jobs) + - `hold_last`: keep returning the last item after exhaustion - `reset` - - 沿触发复位(edge trigger),仅在 `False -> True` 时复位一次。 - - 例如 `Batch Count = 10` 时,如果 `reset` 全程保持 `True`,只会在第一轮复位。 -- `load_always`(仅图片/视频节点) - - `False`: 允许缓存判定,未变化时可复用结果。 - - `True`: 强制每次都执行节点,绕过缓存判定。 - - “每次”按节点执行次数计算;通常 `Batch Count = 10` 会触发 10 次执行。 + - Edge-triggered reset only + - Reset happens only when `reset` changes from `False` to `True` +- `load_always` (image/video nodes) + - `False`: allow normal cache checks + - `True`: force node execution each run ## Nodes @@ -26,25 +35,25 @@ Supports: `.jpg/.jpeg/.png/.webp/.bmp/.tif/.tiff` | Name | Type | Default | Meaning | |---|---|---|---| -| `directory` | `STRING` | `""` | 图片目录路径。 | -| `pattern` | `STRING` | `"*.png,*.jpg,*.jpeg,*.webp"` | 通配符过滤,支持 `,`/`;` 分隔多个模式。 | -| `recursive` | `BOOLEAN` | `False` | 是否递归扫描子目录。 | -| `order` | `ENUM` | `name_asc` | 排序方式:`name_asc/name_desc/mtime_asc/mtime_desc`。 | -| `loop_mode` | `ENUM` | `stop` | 耗尽策略:`loop/stop/hold_last`。 | -| `reset` | `BOOLEAN` | `False` | 沿触发复位信号,仅 `False->True` 生效一次。 | -| `load_always` | `BOOLEAN` | `False` | 是否每次都强制执行节点。 | -| `filename_with_ext` | `BOOLEAN` | `True` | `FILE_NAME` 是否带后缀。 | +| `directory` | `STRING` | `""` | Image directory path. | +| `pattern` | `STRING` | `"*.png,*.jpg,*.jpeg,*.webp"` | Glob patterns, supports comma/semicolon separated values. | +| `recursive` | `BOOLEAN` | `False` | Recursively scan subdirectories. | +| `order` | `ENUM` | `name_asc` | `name_asc/name_desc/mtime_asc/mtime_desc`. | +| `loop_mode` | `ENUM` | `stop` | `loop/stop/hold_last`. | +| `reset` | `BOOLEAN` | `False` | Edge-triggered reset signal. | +| `load_always` | `BOOLEAN` | `False` | Force execution every run. | +| `filename_with_ext` | `BOOLEAN` | `True` | Whether `FILE_NAME` includes extension. | #### Outputs | Name | Type | Meaning | |---|---|---| -| `IMAGE` | `IMAGE` | 当前图片张量(RGB)。 | -| `MASK` | `MASK` | 当前图片掩码(无 alpha 时全 0)。 | -| `FILE_PATH` | `STRING` | 当前图片完整路径。 | -| `FILE_NAME` | `STRING` | 当前图片文件名(受 `filename_with_ext` 控制)。 | -| `INDEX` | `INT` | 当前索引(从 0 开始)。 | -| `TOTAL` | `INT` | 样本总数。 | +| `IMAGE` | `IMAGE` | Current image tensor (RGB). | +| `MASK` | `MASK` | Current mask tensor (all zeros if no alpha channel). | +| `FILE_PATH` | `STRING` | Full path of current image. | +| `FILE_NAME` | `STRING` | File name with or without extension. | +| `INDEX` | `INT` | Current index (0-based). | +| `TOTAL` | `INT` | Total available items. | ### Iterator Load Video Path @@ -54,77 +63,109 @@ Supports: `.mp4/.mov/.mkv/.avi/.webm/.m4v/.mpg/.mpeg/.wmv/.flv` | Name | Type | Default | Meaning | |---|---|---|---| -| `directory` | `STRING` | `""` | 视频目录路径。 | -| `pattern` | `STRING` | `"*.mp4"` | 通配符过滤,支持 `,`/`;` 分隔多个模式。 | -| `recursive` | `BOOLEAN` | `False` | 是否递归扫描子目录。 | -| `order` | `ENUM` | `name_asc` | 排序方式:`name_asc/name_desc/mtime_asc/mtime_desc`。 | -| `loop_mode` | `ENUM` | `stop` | 耗尽策略:`loop/stop/hold_last`。 | -| `reset` | `BOOLEAN` | `False` | 沿触发复位信号,仅 `False->True` 生效一次。 | -| `load_always` | `BOOLEAN` | `False` | 是否每次都强制执行节点。 | -| `filename_with_ext` | `BOOLEAN` | `True` | `FILE_NAME` 是否带后缀。 | +| `directory` | `STRING` | `""` | Video directory path. | +| `pattern` | `STRING` | `"*.mp4"` | Glob patterns, supports comma/semicolon separated values. | +| `recursive` | `BOOLEAN` | `False` | Recursively scan subdirectories. | +| `order` | `ENUM` | `name_asc` | `name_asc/name_desc/mtime_asc/mtime_desc`. | +| `loop_mode` | `ENUM` | `stop` | `loop/stop/hold_last`. | +| `reset` | `BOOLEAN` | `False` | Edge-triggered reset signal. | +| `load_always` | `BOOLEAN` | `False` | Force execution every run. | +| `filename_with_ext` | `BOOLEAN` | `True` | Whether `FILE_NAME` includes extension. | #### Outputs | Name | Type | Meaning | |---|---|---| -| `VIDEO_PATH` | `STRING` | 当前视频完整路径。 | -| `FILE_NAME` | `STRING` | 当前视频文件名(受 `filename_with_ext` 控制)。 | -| `INDEX` | `INT` | 当前索引(从 0 开始)。 | -| `TOTAL` | `INT` | 样本总数。 | +| `VIDEO_PATH` | `STRING` | Full path of current video. | +| `FILE_NAME` | `STRING` | File name with or without extension. | +| `INDEX` | `INT` | Current index (0-based). | +| `TOTAL` | `INT` | Total available items. | ### Iterator Load Text From Dir -Directory mode: 每个文本文件整体作为一个输出项。 +Each text file is emitted as one output item. Supports: `.txt/.md/.prompt/.json/.jsonl` #### Inputs | Name | Type | Default | Meaning | |---|---|---|---| -| `directory` | `STRING` | `""` | 文本目录路径。 | -| `pattern` | `STRING` | `"*.txt,*.md,*.prompt,*.json,*.jsonl"` | 通配符过滤,支持 `,`/`;` 分隔多个模式。 | -| `recursive` | `BOOLEAN` | `False` | 是否递归扫描子目录。 | -| `order` | `ENUM` | `name_asc` | 排序方式:`name_asc/name_desc/mtime_asc/mtime_desc`。 | -| `encoding` | `STRING` | `"utf-8"` | 文本读取编码。 | -| `loop_mode` | `ENUM` | `stop` | 耗尽策略:`loop/stop/hold_last`。 | -| `reset` | `BOOLEAN` | `False` | 沿触发复位信号,仅 `False->True` 生效一次。 | +| `directory` | `STRING` | `""` | Text directory path. | +| `pattern` | `STRING` | `"*.txt,*.md,*.prompt,*.json,*.jsonl"` | Glob patterns, supports comma/semicolon separated values. | +| `recursive` | `BOOLEAN` | `False` | Recursively scan subdirectories. | +| `order` | `ENUM` | `name_asc` | `name_asc/name_desc/mtime_asc/mtime_desc`. | +| `encoding` | `STRING` | `"utf-8"` | File encoding. | +| `loop_mode` | `ENUM` | `stop` | `loop/stop/hold_last`. | +| `reset` | `BOOLEAN` | `False` | Edge-triggered reset signal. | #### Outputs | Name | Type | Meaning | |---|---|---| -| `TEXT` | `STRING` | 当前文本文件内容。 | -| `SOURCE_PATH` | `STRING` | 当前文本文件完整路径。 | -| `INDEX` | `INT` | 当前索引(从 0 开始)。 | -| `TOTAL` | `INT` | 样本总数。 | +| `TEXT` | `STRING` | Full text content of current file. | +| `SOURCE_PATH` | `STRING` | Full path of current file. | +| `INDEX` | `INT` | Current index (0-based). | +| `TOTAL` | `INT` | Total available items. | ### Iterator Load Text From File -File mode: 从单文件切分或解析多条文本。 +Loads multiple text items from a single file. #### Inputs | Name | Type | Default | Meaning | |---|---|---|---| -| `file_path` | `STRING` | `""` | 文本文件路径。 | -| `parse_mode` | `ENUM` | `file_split` | 解析方式:`file_split/json_array/jsonl`。 | -| `delimiter` | `STRING` | `"\\n---\\n"` | `file_split` 分隔符,支持转义字符。 | -| `json_field` | `STRING` | `""` | `json_array/jsonl` 对象项提取字段,支持点路径。 | -| `encoding` | `STRING` | `"utf-8"` | 文本读取编码。 | -| `loop_mode` | `ENUM` | `stop` | 耗尽策略:`loop/stop/hold_last`。 | -| `reset` | `BOOLEAN` | `False` | 沿触发复位信号,仅 `False->True` 生效一次。 | +| `file_path` | `STRING` | `""` | Input file path. | +| `parse_mode` | `ENUM` | `file_split` | `file_split/json_array/jsonl`. | +| `delimiter` | `STRING` | `"\\n---\\n"` | Delimiter for `file_split` mode (supports escaped characters). | +| `json_field` | `STRING` | `""` | Field path for object entries (dot path supported). | +| `encoding` | `STRING` | `"utf-8"` | File encoding. | +| `loop_mode` | `ENUM` | `stop` | `loop/stop/hold_last`. | +| `reset` | `BOOLEAN` | `False` | Edge-triggered reset signal. | #### Outputs | Name | Type | Meaning | |---|---|---| -| `TEXT` | `STRING` | 当前文本片段或记录内容。 | -| `SOURCE_PATH` | `STRING` | 来源位置(`#分片索引` 或 `:行号`)。 | -| `INDEX` | `INT` | 当前索引(从 0 开始)。 | -| `TOTAL` | `INT` | 样本总数。 | +| `TEXT` | `STRING` | Current text segment or record. | +| `SOURCE_PATH` | `STRING` | Source marker (`#segment` or `:line`). | +| `INDEX` | `INT` | Current index (0-based). | +| `TOTAL` | `INT` | Total available items. | -## Cursor State +## Minimal Workflow Examples -Cursor state is persisted in `.iterator_state.json`. +1. Image batch processing + - `Iterator Load Image` -> existing image workflow head node +2. Video batch processing + - `Iterator Load Video Path` -> your video path-based loader node +3. Prompt/text batch processing + - `Iterator Load Text From Dir` or `Iterator Load Text From File` -> prompt input branch -Scope is isolated by node unique id + source settings, so different nodes do not share the same cursor. +## Installation + +1. Clone this repo into your ComfyUI `custom_nodes` directory. +2. Install runtime dependencies in the same Python environment used by ComfyUI: + +```bash +python -m pip install -r requirements.txt +``` + +3. Restart ComfyUI. + +Notes: +- `torch` is expected to be provided by your ComfyUI environment. +- Keep your `torch` version aligned with your ComfyUI setup. + +## Testing + +Run tests: + +```bash +python -m pip install pytest +python -m pytest +``` + +## State File + +- Cursor state is stored in `.iterator_state.json` +- The file is ignored by git diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000..78c5c6a --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,169 @@ +# ComfyUI-Simple-Iterator + +用于图片、视频、文本“每次运行只输出一个样本”的 ComfyUI 自定义节点集。 + +语言: [English](./README.md) | **简体中文** + +## 功能特性 + +- 持久化游标状态(`.iterator_state.json`) +- 按节点 id + 数据源配置隔离状态 +- `reset` 沿触发复位(`False -> True`) +- `stop` 模式防止批处理中静默重复 +- 支持输出带后缀或不带后缀的文件名 + +## 通用语义 + +- `loop_mode` + - `loop`:到末尾后回到第一项 + - `stop`:到末尾后下一次执行报错停止(批处理推荐) + - `hold_last`:到末尾后持续返回最后一项 +- `reset` + - 仅沿触发复位 + - 只有从 `False` 变为 `True` 时才复位 +- `load_always`(图片/视频节点) + - `False`:按正常缓存判定 + - `True`:每次都强制执行 + +## 节点说明 + +### Iterator Load Image + +支持后缀:`.jpg/.jpeg/.png/.webp/.bmp/.tif/.tiff` + +#### 输入参数 + +| 名称 | 类型 | 默认值 | 含义 | +|---|---|---|---| +| `directory` | `STRING` | `""` | 图片目录路径 | +| `pattern` | `STRING` | `"*.png,*.jpg,*.jpeg,*.webp"` | 文件匹配模式,支持逗号/分号分隔 | +| `recursive` | `BOOLEAN` | `False` | 是否递归扫描子目录 | +| `order` | `ENUM` | `name_asc` | `name_asc/name_desc/mtime_asc/mtime_desc` | +| `loop_mode` | `ENUM` | `stop` | `loop/stop/hold_last` | +| `reset` | `BOOLEAN` | `False` | 沿触发复位信号 | +| `load_always` | `BOOLEAN` | `False` | 是否每次强制执行 | +| `filename_with_ext` | `BOOLEAN` | `True` | `FILE_NAME` 是否带后缀 | + +#### 输出参数 + +| 名称 | 类型 | 含义 | +|---|---|---| +| `IMAGE` | `IMAGE` | 当前图片张量(RGB) | +| `MASK` | `MASK` | 当前掩码(无 alpha 时全 0) | +| `FILE_PATH` | `STRING` | 当前图片完整路径 | +| `FILE_NAME` | `STRING` | 当前文件名(可带后缀) | +| `INDEX` | `INT` | 当前索引(0 开始) | +| `TOTAL` | `INT` | 样本总数 | + +### Iterator Load Video Path + +支持后缀:`.mp4/.mov/.mkv/.avi/.webm/.m4v/.mpg/.mpeg/.wmv/.flv` + +#### 输入参数 + +| 名称 | 类型 | 默认值 | 含义 | +|---|---|---|---| +| `directory` | `STRING` | `""` | 视频目录路径 | +| `pattern` | `STRING` | `"*.mp4"` | 文件匹配模式,支持逗号/分号分隔 | +| `recursive` | `BOOLEAN` | `False` | 是否递归扫描子目录 | +| `order` | `ENUM` | `name_asc` | `name_asc/name_desc/mtime_asc/mtime_desc` | +| `loop_mode` | `ENUM` | `stop` | `loop/stop/hold_last` | +| `reset` | `BOOLEAN` | `False` | 沿触发复位信号 | +| `load_always` | `BOOLEAN` | `False` | 是否每次强制执行 | +| `filename_with_ext` | `BOOLEAN` | `True` | `FILE_NAME` 是否带后缀 | + +#### 输出参数 + +| 名称 | 类型 | 含义 | +|---|---|---| +| `VIDEO_PATH` | `STRING` | 当前视频完整路径 | +| `FILE_NAME` | `STRING` | 当前文件名(可带后缀) | +| `INDEX` | `INT` | 当前索引(0 开始) | +| `TOTAL` | `INT` | 样本总数 | + +### Iterator Load Text From Dir + +目录模式:每个文本文件整体作为一条输出。 +支持后缀:`.txt/.md/.prompt/.json/.jsonl` + +#### 输入参数 + +| 名称 | 类型 | 默认值 | 含义 | +|---|---|---|---| +| `directory` | `STRING` | `""` | 文本目录路径 | +| `pattern` | `STRING` | `"*.txt,*.md,*.prompt,*.json,*.jsonl"` | 文件匹配模式,支持逗号/分号分隔 | +| `recursive` | `BOOLEAN` | `False` | 是否递归扫描子目录 | +| `order` | `ENUM` | `name_asc` | `name_asc/name_desc/mtime_asc/mtime_desc` | +| `encoding` | `STRING` | `"utf-8"` | 文件编码 | +| `loop_mode` | `ENUM` | `stop` | `loop/stop/hold_last` | +| `reset` | `BOOLEAN` | `False` | 沿触发复位信号 | + +#### 输出参数 + +| 名称 | 类型 | 含义 | +|---|---|---| +| `TEXT` | `STRING` | 当前文本内容(整文件) | +| `SOURCE_PATH` | `STRING` | 当前文件完整路径 | +| `INDEX` | `INT` | 当前索引(0 开始) | +| `TOTAL` | `INT` | 样本总数 | + +### Iterator Load Text From File + +单文件模式:从一个文件中拆分/解析出多条文本。 + +#### 输入参数 + +| 名称 | 类型 | 默认值 | 含义 | +|---|---|---|---| +| `file_path` | `STRING` | `""` | 输入文件路径 | +| `parse_mode` | `ENUM` | `file_split` | `file_split/json_array/jsonl` | +| `delimiter` | `STRING` | `"\\n---\\n"` | `file_split` 分隔符(支持转义) | +| `json_field` | `STRING` | `""` | 对象记录取值字段(支持点路径) | +| `encoding` | `STRING` | `"utf-8"` | 文件编码 | +| `loop_mode` | `ENUM` | `stop` | `loop/stop/hold_last` | +| `reset` | `BOOLEAN` | `False` | 沿触发复位信号 | + +#### 输出参数 + +| 名称 | 类型 | 含义 | +|---|---|---| +| `TEXT` | `STRING` | 当前文本片段/记录 | +| `SOURCE_PATH` | `STRING` | 来源标记(`#分片` 或 `:行号`) | +| `INDEX` | `INT` | 当前索引(0 开始) | +| `TOTAL` | `INT` | 样本总数 | + +## 最小工作流示例 + +1. 图片批处理 + `Iterator Load Image` -> 现有图片工作流入口 +2. 视频批处理 + `Iterator Load Video Path` -> 你的视频路径加载节点 +3. 文本批处理 + `Iterator Load Text From Dir` 或 `Iterator Load Text From File` -> prompt 分支 + +## 安装 + +1. 将本仓库克隆到 ComfyUI 的 `custom_nodes` 目录。 +2. 在与 ComfyUI 相同的 Python 环境中安装运行依赖: + +```bash +python -m pip install -r requirements.txt +``` + +3. 重启 ComfyUI。 + +说明: +- `torch` 默认由 ComfyUI 运行环境提供。 +- 请保持 `torch` 与你的 ComfyUI 环境版本一致。 + +## 测试 + +```bash +python -m pip install pytest +python -m pytest +``` + +## 状态文件 + +- 游标状态保存在 `.iterator_state.json` +- 该文件已加入 git ignore diff --git a/iterator_core.py b/iterator_core.py index 6b3dfb8..467cf5a 100644 --- a/iterator_core.py +++ b/iterator_core.py @@ -20,6 +20,11 @@ def stable_scope(kind: str, unique_id: str, source_key: str) -> str: return _sha1((kind, unique_id or "default", source_key)) +def format_output_filename(path: Path, with_ext: bool) -> str: + """根据开关返回文件名(带后缀或不带后缀)。""" + return path.name if with_ext else path.stem + + class IteratorStateStore: """定义迭代游标的持久化存取契约。 diff --git a/nodes.py b/nodes.py index 85fd791..5d17824 100644 --- a/nodes.py +++ b/nodes.py @@ -8,7 +8,7 @@ import numpy as np import torch from PIL import Image, ImageOps -from .iterator_core import IteratorStateStore, stable_scope +from .iterator_core import IteratorStateStore, format_output_filename, stable_scope IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tiff", ".tif"} @@ -428,7 +428,7 @@ class IteratorLoadImage: ) selected = paths[index] image, mask = _load_image_tensor(selected) - file_name = selected.name if filename_with_ext else selected.stem + file_name = format_output_filename(selected, filename_with_ext) return image, mask, str(selected), file_name, index, len(paths) @@ -534,7 +534,7 @@ class IteratorLoadVideoPath: scope_key=scope_key, total=len(paths), reset=reset, loop_mode=loop_mode ) selected = paths[index] - file_name = selected.name if filename_with_ext else selected.stem + file_name = format_output_filename(selected, filename_with_ext) return str(selected), file_name, index, len(paths) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..239e9ae --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,21 @@ +[project] +name = "ComfyUI-Simple-Iterator" +version = "0.1.0" +description = "ComfyUI custom nodes for one-sample-per-run iteration across image, video, and text sources." +readme = "README.md" +requires-python = ">=3.10" +license = { file = "LICENSE" } +dependencies = [ + "numpy>=1.24", + "Pillow>=10.0", +] + +[project.optional-dependencies] +test = [ + "pytest>=8.0", +] + +[tool.comfy] +PublisherId = "simple-iterator" +DisplayName = "ComfyUI-Simple-Iterator" +Icon = "" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..220ee24 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +numpy>=1.24 +Pillow>=10.0 diff --git a/tests/test_iterator_core.py b/tests/test_iterator_core.py new file mode 100644 index 0000000..9410c72 --- /dev/null +++ b/tests/test_iterator_core.py @@ -0,0 +1,76 @@ +import shutil +import uuid +from pathlib import Path + +import pytest + +from iterator_core import IteratorStateStore, format_output_filename, stable_scope + + +def _reset_store(state_path: Path) -> None: + state_path.parent.mkdir(parents=True, exist_ok=True) + IteratorStateStore._state_file = state_path + IteratorStateStore._state = {} + IteratorStateStore._loaded = False + + +def _state_path() -> Path: + return Path(__file__).resolve().parent / ".tmp" / f"state-{uuid.uuid4().hex}.json" + + +def _cleanup(path: Path) -> None: + if path.parent.exists(): + shutil.rmtree(path.parent, ignore_errors=True) + + +def test_sequence_progression(): + state_path = _state_path() + _reset_store(state_path) + scope = stable_scope("video", "node-a", "source-a") + + indexes = [ + IteratorStateStore.claim_index(scope_key=scope, total=3, reset=False, loop_mode="loop"), + IteratorStateStore.claim_index(scope_key=scope, total=3, reset=False, loop_mode="loop"), + IteratorStateStore.claim_index(scope_key=scope, total=3, reset=False, loop_mode="loop"), + ] + + assert indexes == [0, 1, 2] + _cleanup(state_path) + + +def test_stop_mode_raises_when_exhausted(): + state_path = _state_path() + _reset_store(state_path) + scope = stable_scope("video", "node-b", "source-b") + + IteratorStateStore.claim_index(scope_key=scope, total=2, reset=False, loop_mode="stop") + IteratorStateStore.claim_index(scope_key=scope, total=2, reset=False, loop_mode="stop") + + with pytest.raises(RuntimeError, match="Iterator exhausted"): + IteratorStateStore.claim_index(scope_key=scope, total=2, reset=False, loop_mode="stop") + _cleanup(state_path) + + +def test_reset_edge_trigger(): + state_path = _state_path() + _reset_store(state_path) + scope = stable_scope("video", "node-c", "source-c") + + # First run, no reset: output index 0. + i0 = IteratorStateStore.claim_index(scope_key=scope, total=5, reset=False, loop_mode="loop") + # Rising edge False -> True: reset once, output index 0. + i1 = IteratorStateStore.claim_index(scope_key=scope, total=5, reset=True, loop_mode="loop") + # Keep True: no second reset, should continue to index 1. + i2 = IteratorStateStore.claim_index(scope_key=scope, total=5, reset=True, loop_mode="loop") + # Arm the next reset by setting False, then rising edge again. + IteratorStateStore.claim_index(scope_key=scope, total=5, reset=False, loop_mode="loop") + i3 = IteratorStateStore.claim_index(scope_key=scope, total=5, reset=True, loop_mode="loop") + + assert (i0, i1, i2, i3) == (0, 0, 1, 0) + _cleanup(state_path) + + +def test_filename_output_with_and_without_extension(): + sample = Path("demo_video.mp4") + assert format_output_filename(sample, True) == "demo_video.mp4" + assert format_output_filename(sample, False) == "demo_video"