""" Filename formatter for customizing output filenames using template syntax. Example usage: from shared.utils.filename_formatter import FilenameFormatter template = "{date}-{prompt(50)}-{seed}" settings = {"prompt": "A beautiful sunset over the ocean", "seed": 12345} filename = FilenameFormatter.format_filename(template, settings) # Result: "2025-01-15-14h30m45s-A_beautiful_sunset_over_the_ocean-12345" Date format examples: {date} -> 2025-01-15-14h30m45s (default) {date(YYYY-MM-DD)} -> 2025-01-15 {date(YYYY/MM/DD)} -> 2025/01/15 {date(DD.MM.YYYY)} -> 15.01.2025 {date(YYYY-MM-DD_HH-mm-ss)} -> 2025-01-15_14-30-45 {date(HHhmm)} -> 14h30 """ import re import time from datetime import datetime class FilenameFormatter: """ Formats output filenames using template syntax with settings values. Supported placeholders: - {date} - timestamp with default format YYYY-MM-DD-HHhmmss - {date(YYYY-MM-DD)} - date with custom format and separator - {date(YYYY-MM-DD_HH-mm-ss)} - date and time with custom separators - {seed} - generation seed - {resolution} - video resolution (e.g., "1280x720") - {num_inference_steps} or {steps} - number of inference steps - {prompt} or {prompt(50)} - prompt text with optional max length - {flow_shift} - flow shift value - {video_length} or {frames} - video length in frames - {guidance_scale} or {cfg} - guidance scale value Date format tokens: - YYYY: 4-digit year (2025) - YY: 2-digit year (25) - MM: 2-digit month (01-12) - DD: 2-digit day (01-31) - HH: 2-digit hour 24h (00-23) - hh: 2-digit hour 12h (01-12) - mm: 2-digit minute (00-59) - ss: 2-digit second (00-59) - Separators: - _ . : / and space Example templates: - "{date}-{prompt(50)}-{seed}" - "{date(YYYYMMDD)}-{resolution}-{steps}steps" - "{date(YYYY-MM-DD_HH-mm-ss)}_{seed}" """ # Allowed placeholder keys (with aliases) ALLOWED_KEYS = { 'date', 'seed', 'resolution', 'num_inference_steps', 'steps', 'prompt', 'flow_shift', 'video_length', 'frames', 'guidance_scale', 'cfg' } # Map aliases to actual setting keys KEY_ALIASES = { 'steps': 'num_inference_steps', 'frames': 'video_length', 'cfg': 'guidance_scale' } # Pattern to match placeholders: {key}, {key(arg)}, or {key(%format)} PLACEHOLDER_PATTERN = re.compile(r'\{(\w+)(?:\(([^)]*)\))?\}') # Date token to strftime mapping (order matters - longer tokens first) DATE_TOKENS = [ ('YYYY', '%Y'), # 4-digit year ('YY', '%y'), # 2-digit year ('MM', '%m'), # 2-digit month ('DD', '%d'), # 2-digit day ('HH', '%H'), # 2-digit hour (24h) ('hh', '%I'), # 2-digit hour (12h) ('mm', '%M'), # 2-digit minute ('ss', '%S'), # 2-digit second ] # Allowed separator characters in date format DATE_SEPARATORS = set('-_.:/ h') # Characters not allowed in filenames (covers Windows, macOS, Linux) UNSAFE_FILENAME_CHARS = re.compile(r'[<>:"/\\|?*\x00-\x1f\n\r\t/]') def __init__(self, template: str): """ Initialize with a template string. Args: template: Format string like "{date}-{prompt(50)}-{seed}" Raises: ValueError: If template contains unknown placeholders """ self.template = template self._validate_template() def _validate_template(self): """Validate that template only uses allowed placeholders.""" for match in self.PLACEHOLDER_PATTERN.finditer(self.template): key = match.group(1) if key not in self.ALLOWED_KEYS: allowed = ', '.join(sorted(self.ALLOWED_KEYS)) raise ValueError(f"Unknown placeholder: {{{key}}}. Allowed: {allowed}") def _parse_date_format(self, fmt: str) -> str: """ Convert user-friendly date format to strftime format. Args: fmt: User format like "YYYY-MM-DD" or "YYYY/MM/DD_HH-mm-ss" Returns: strftime format string like "%Y-%m-%d" or "%Y/%m/%d_%H-%M-%S" """ result = fmt # Replace tokens with strftime codes (longer tokens first to avoid partial matches) for token, strftime_code in self.DATE_TOKENS: result = result.replace(token, strftime_code) return result def _is_valid_date_format(self, fmt: str) -> bool: """ Check if date format string contains only valid tokens and separators. Args: fmt: User format string to validate Returns: True if format is valid and safe """ # Make a copy to check remaining = fmt # Remove all valid tokens for token, _ in self.DATE_TOKENS: remaining = remaining.replace(token, '') # What's left should only be separators return all(c in self.DATE_SEPARATORS for c in remaining) def _format_date(self, arg: str = None) -> str: """ Format current timestamp. Args: arg: Optional date format string like "YYYY-MM-DD" or "HH:mm:ss" If None or invalid, uses default format. Returns: Formatted date/time string """ default_fmt = "%Y-%m-%d-%Hh%Mm%Ss" if arg is None: strftime_fmt = default_fmt elif self._is_valid_date_format(arg): strftime_fmt = self._parse_date_format(arg) else: # Invalid format, use default strftime_fmt = default_fmt try: return datetime.fromtimestamp(time.time()).strftime(strftime_fmt) except Exception: return datetime.fromtimestamp(time.time()).strftime(default_fmt) def _truncate(self, value: str, max_len: int) -> str: """Truncate string to max length.""" if max_len <= 0 or len(value) <= max_len: return value return value[:max_len].rstrip() def _sanitize_for_filename(self, value: str) -> str: """ Remove/replace characters unsafe for filenames. - Replaces unsafe chars with underscore - Collapses multiple underscores/spaces - Strips leading/trailing underscores and spaces """ if not value: return '' # Replace unsafe chars with underscore sanitized = self.UNSAFE_FILENAME_CHARS.sub('_', str(value)) # Replace multiple underscores/spaces with single underscore sanitized = re.sub(r'[_\s]+', '_', sanitized) # Strip leading/trailing underscores and spaces return sanitized.strip('_ ') def format(self, settings: dict) -> str: """ Format the template with settings values. Args: settings: Dictionary containing settings values Returns: Formatted filename (without extension), safe for filesystem """ def replace_placeholder(match): key = match.group(1) arg = match.group(2) # Optional argument in parentheses # Handle date specially if key == 'date': return self._format_date(arg) # Resolve aliases actual_key = self.KEY_ALIASES.get(key, key) # Get value from settings value = settings.get(actual_key) # Convert to string if value is None: value = '' else: value = str(value) # Apply truncation if specified (for text fields) if arg is not None and arg.isdigit(): max_len = int(arg) value = self._truncate(value, max_len) return self._sanitize_for_filename(value) result = self.PLACEHOLDER_PATTERN.sub(replace_placeholder, self.template) # Sanitize any literal text in template that might be unsafe result = self._sanitize_for_filename(result) # Ensure result is not empty if not result: result = self._format_date() return result @classmethod def format_filename(cls, template: str, settings: dict) -> str: """ Convenience class method to format a filename in one call. Args: template: Format string like "{date}-{prompt(50)}-{seed}" settings: Dictionary containing settings values Returns: Formatted filename (without extension) Raises: ValueError: If template contains unknown placeholders """ formatter = cls(template) return formatter.format(settings) @classmethod def get_help_text(cls) -> str: """Return help text describing the template syntax.""" return """Filename Template Syntax: Placeholders (wrap in curly braces): {date} - Timestamp (default: 2025-01-15-14h30m45s) {date(YYYY-MM-DD)} - Date with custom format {date(HH-mm-ss)} - Time only {date(YYYY-MM-DD_HH-mm-ss)} - Date and time {seed} - Generation seed {resolution} - Video resolution (e.g., 1280x720) {num_inference_steps} - Number of inference steps (alias: {steps}) {prompt} - Full prompt text {prompt(50)} - Prompt truncated to 50 characters {flow_shift} - Flow shift value {video_length} - Video length in frames (alias: {frames}) {guidance_scale} - Guidance scale (alias: {cfg}) Date/Time tokens: YYYY - 4-digit year MM - month (01-12) DD - day (01-31) HH - hour 24h (00-23) hh - hour 12h (01-12) mm - minute (00-59) ss - second (00-59) Separators: - _ . : / space h Examples: {date}-{prompt(50)}-{seed} {date(YYYYMMDD)}_{resolution}_{steps}steps {date(YYYY-MM-DD_HH-mm-ss)}_{seed} {date(DD.MM.YYYY)}_{prompt(30)} """