Merge pull request '001-video-wall' (#52) from 001-video-wall into master

Reviewed-on: #52
This commit was merged in pull request #52.
This commit is contained in:
2026-03-02 18:45:03 +00:00
43 changed files with 5509 additions and 18 deletions

View File

@@ -0,0 +1,184 @@
---
description: Perform a non-destructive cross-artifact consistency and quality analysis across spec.md, plan.md, and tasks.md after task generation.
---
## User Input
```text
$ARGUMENTS
```
You **MUST** consider the user input before proceeding (if not empty).
## Goal
Identify inconsistencies, duplications, ambiguities, and underspecified items across the three core artifacts (`spec.md`, `plan.md`, `tasks.md`) before implementation. This command MUST run only after `/speckit.tasks` has successfully produced a complete `tasks.md`.
## Operating Constraints
**STRICTLY READ-ONLY**: Do **not** modify any files. Output a structured analysis report. Offer an optional remediation plan (user must explicitly approve before any follow-up editing commands would be invoked manually).
**Constitution Authority**: The project constitution (`.specify/memory/constitution.md`) is **non-negotiable** within this analysis scope. Constitution conflicts are automatically CRITICAL and require adjustment of the spec, plan, or tasks—not dilution, reinterpretation, or silent ignoring of the principle. If a principle itself needs to change, that must occur in a separate, explicit constitution update outside `/speckit.analyze`.
## Execution Steps
### 1. Initialize Analysis Context
Run `.specify/scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks` once from repo root and parse JSON for FEATURE_DIR and AVAILABLE_DOCS. Derive absolute paths:
- SPEC = FEATURE_DIR/spec.md
- PLAN = FEATURE_DIR/plan.md
- TASKS = FEATURE_DIR/tasks.md
Abort with an error message if any required file is missing (instruct the user to run missing prerequisite command).
For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
### 2. Load Artifacts (Progressive Disclosure)
Load only the minimal necessary context from each artifact:
**From spec.md:**
- Overview/Context
- Functional Requirements
- Non-Functional Requirements
- User Stories
- Edge Cases (if present)
**From plan.md:**
- Architecture/stack choices
- Data Model references
- Phases
- Technical constraints
**From tasks.md:**
- Task IDs
- Descriptions
- Phase grouping
- Parallel markers [P]
- Referenced file paths
**From constitution:**
- Load `.specify/memory/constitution.md` for principle validation
### 3. Build Semantic Models
Create internal representations (do not include raw artifacts in output):
- **Requirements inventory**: Each functional + non-functional requirement with a stable key (derive slug based on imperative phrase; e.g., "User can upload file" → `user-can-upload-file`)
- **User story/action inventory**: Discrete user actions with acceptance criteria
- **Task coverage mapping**: Map each task to one or more requirements or stories (inference by keyword / explicit reference patterns like IDs or key phrases)
- **Constitution rule set**: Extract principle names and MUST/SHOULD normative statements
### 4. Detection Passes (Token-Efficient Analysis)
Focus on high-signal findings. Limit to 50 findings total; aggregate remainder in overflow summary.
#### A. Duplication Detection
- Identify near-duplicate requirements
- Mark lower-quality phrasing for consolidation
#### B. Ambiguity Detection
- Flag vague adjectives (fast, scalable, secure, intuitive, robust) lacking measurable criteria
- Flag unresolved placeholders (TODO, TKTK, ???, `<placeholder>`, etc.)
#### C. Underspecification
- Requirements with verbs but missing object or measurable outcome
- User stories missing acceptance criteria alignment
- Tasks referencing files or components not defined in spec/plan
#### D. Constitution Alignment
- Any requirement or plan element conflicting with a MUST principle
- Missing mandated sections or quality gates from constitution
#### E. Coverage Gaps
- Requirements with zero associated tasks
- Tasks with no mapped requirement/story
- Non-functional requirements not reflected in tasks (e.g., performance, security)
#### F. Inconsistency
- Terminology drift (same concept named differently across files)
- Data entities referenced in plan but absent in spec (or vice versa)
- Task ordering contradictions (e.g., integration tasks before foundational setup tasks without dependency note)
- Conflicting requirements (e.g., one requires Next.js while other specifies Vue)
### 5. Severity Assignment
Use this heuristic to prioritize findings:
- **CRITICAL**: Violates constitution MUST, missing core spec artifact, or requirement with zero coverage that blocks baseline functionality
- **HIGH**: Duplicate or conflicting requirement, ambiguous security/performance attribute, untestable acceptance criterion
- **MEDIUM**: Terminology drift, missing non-functional task coverage, underspecified edge case
- **LOW**: Style/wording improvements, minor redundancy not affecting execution order
### 6. Produce Compact Analysis Report
Output a Markdown report (no file writes) with the following structure:
## Specification Analysis Report
| ID | Category | Severity | Location(s) | Summary | Recommendation |
|----|----------|----------|-------------|---------|----------------|
| A1 | Duplication | HIGH | spec.md:L120-134 | Two similar requirements ... | Merge phrasing; keep clearer version |
(Add one row per finding; generate stable IDs prefixed by category initial.)
**Coverage Summary Table:**
| Requirement Key | Has Task? | Task IDs | Notes |
|-----------------|-----------|----------|-------|
**Constitution Alignment Issues:** (if any)
**Unmapped Tasks:** (if any)
**Metrics:**
- Total Requirements
- Total Tasks
- Coverage % (requirements with >=1 task)
- Ambiguity Count
- Duplication Count
- Critical Issues Count
### 7. Provide Next Actions
At end of report, output a concise Next Actions block:
- If CRITICAL issues exist: Recommend resolving before `/speckit.implement`
- If only LOW/MEDIUM: User may proceed, but provide improvement suggestions
- Provide explicit command suggestions: e.g., "Run /speckit.specify with refinement", "Run /speckit.plan to adjust architecture", "Manually edit tasks.md to add coverage for 'performance-metrics'"
### 8. Offer Remediation
Ask the user: "Would you like me to suggest concrete remediation edits for the top N issues?" (Do NOT apply them automatically.)
## Operating Principles
### Context Efficiency
- **Minimal high-signal tokens**: Focus on actionable findings, not exhaustive documentation
- **Progressive disclosure**: Load artifacts incrementally; don't dump all content into analysis
- **Token-efficient output**: Limit findings table to 50 rows; summarize overflow
- **Deterministic results**: Rerunning without changes should produce consistent IDs and counts
### Analysis Guidelines
- **NEVER modify files** (this is read-only analysis)
- **NEVER hallucinate missing sections** (if absent, report them accurately)
- **Prioritize constitution violations** (these are always CRITICAL)
- **Use examples over exhaustive rules** (cite specific instances, not generic patterns)
- **Report zero issues gracefully** (emit success report with coverage statistics)
## Context
$ARGUMENTS

View File

@@ -0,0 +1,294 @@
---
description: Generate a custom checklist for the current feature based on user requirements.
---
## Checklist Purpose: "Unit Tests for English"
**CRITICAL CONCEPT**: Checklists are **UNIT TESTS FOR REQUIREMENTS WRITING** - they validate the quality, clarity, and completeness of requirements in a given domain.
**NOT for verification/testing**:
- ❌ NOT "Verify the button clicks correctly"
- ❌ NOT "Test error handling works"
- ❌ NOT "Confirm the API returns 200"
- ❌ NOT checking if code/implementation matches the spec
**FOR requirements quality validation**:
- ✅ "Are visual hierarchy requirements defined for all card types?" (completeness)
- ✅ "Is 'prominent display' quantified with specific sizing/positioning?" (clarity)
- ✅ "Are hover state requirements consistent across all interactive elements?" (consistency)
- ✅ "Are accessibility requirements defined for keyboard navigation?" (coverage)
- ✅ "Does the spec define what happens when logo image fails to load?" (edge cases)
**Metaphor**: If your spec is code written in English, the checklist is its unit test suite. You're testing whether the requirements are well-written, complete, unambiguous, and ready for implementation - NOT whether the implementation works.
## User Input
```text
$ARGUMENTS
```
You **MUST** consider the user input before proceeding (if not empty).
## Execution Steps
1. **Setup**: Run `.specify/scripts/powershell/check-prerequisites.ps1 -Json` from repo root and parse JSON for FEATURE_DIR and AVAILABLE_DOCS list.
- All file paths must be absolute.
- For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
2. **Clarify intent (dynamic)**: Derive up to THREE initial contextual clarifying questions (no pre-baked catalog). They MUST:
- Be generated from the user's phrasing + extracted signals from spec/plan/tasks
- Only ask about information that materially changes checklist content
- Be skipped individually if already unambiguous in `$ARGUMENTS`
- Prefer precision over breadth
Generation algorithm:
1. Extract signals: feature domain keywords (e.g., auth, latency, UX, API), risk indicators ("critical", "must", "compliance"), stakeholder hints ("QA", "review", "security team"), and explicit deliverables ("a11y", "rollback", "contracts").
2. Cluster signals into candidate focus areas (max 4) ranked by relevance.
3. Identify probable audience & timing (author, reviewer, QA, release) if not explicit.
4. Detect missing dimensions: scope breadth, depth/rigor, risk emphasis, exclusion boundaries, measurable acceptance criteria.
5. Formulate questions chosen from these archetypes:
- Scope refinement (e.g., "Should this include integration touchpoints with X and Y or stay limited to local module correctness?")
- Risk prioritization (e.g., "Which of these potential risk areas should receive mandatory gating checks?")
- Depth calibration (e.g., "Is this a lightweight pre-commit sanity list or a formal release gate?")
- Audience framing (e.g., "Will this be used by the author only or peers during PR review?")
- Boundary exclusion (e.g., "Should we explicitly exclude performance tuning items this round?")
- Scenario class gap (e.g., "No recovery flows detected—are rollback / partial failure paths in scope?")
Question formatting rules:
- If presenting options, generate a compact table with columns: Option | Candidate | Why It Matters
- Limit to AE options maximum; omit table if a free-form answer is clearer
- Never ask the user to restate what they already said
- Avoid speculative categories (no hallucination). If uncertain, ask explicitly: "Confirm whether X belongs in scope."
Defaults when interaction impossible:
- Depth: Standard
- Audience: Reviewer (PR) if code-related; Author otherwise
- Focus: Top 2 relevance clusters
Output the questions (label Q1/Q2/Q3). After answers: if ≥2 scenario classes (Alternate / Exception / Recovery / Non-Functional domain) remain unclear, you MAY ask up to TWO more targeted followups (Q4/Q5) with a one-line justification each (e.g., "Unresolved recovery path risk"). Do not exceed five total questions. Skip escalation if user explicitly declines more.
3. **Understand user request**: Combine `$ARGUMENTS` + clarifying answers:
- Derive checklist theme (e.g., security, review, deploy, ux)
- Consolidate explicit must-have items mentioned by user
- Map focus selections to category scaffolding
- Infer any missing context from spec/plan/tasks (do NOT hallucinate)
4. **Load feature context**: Read from FEATURE_DIR:
- spec.md: Feature requirements and scope
- plan.md (if exists): Technical details, dependencies
- tasks.md (if exists): Implementation tasks
**Context Loading Strategy**:
- Load only necessary portions relevant to active focus areas (avoid full-file dumping)
- Prefer summarizing long sections into concise scenario/requirement bullets
- Use progressive disclosure: add follow-on retrieval only if gaps detected
- If source docs are large, generate interim summary items instead of embedding raw text
5. **Generate checklist** - Create "Unit Tests for Requirements":
- Create `FEATURE_DIR/checklists/` directory if it doesn't exist
- Generate unique checklist filename:
- Use short, descriptive name based on domain (e.g., `ux.md`, `api.md`, `security.md`)
- Format: `[domain].md`
- If file exists, append to existing file
- Number items sequentially starting from CHK001
- Each `/speckit.checklist` run creates a NEW file (never overwrites existing checklists)
**CORE PRINCIPLE - Test the Requirements, Not the Implementation**:
Every checklist item MUST evaluate the REQUIREMENTS THEMSELVES for:
- **Completeness**: Are all necessary requirements present?
- **Clarity**: Are requirements unambiguous and specific?
- **Consistency**: Do requirements align with each other?
- **Measurability**: Can requirements be objectively verified?
- **Coverage**: Are all scenarios/edge cases addressed?
**Category Structure** - Group items by requirement quality dimensions:
- **Requirement Completeness** (Are all necessary requirements documented?)
- **Requirement Clarity** (Are requirements specific and unambiguous?)
- **Requirement Consistency** (Do requirements align without conflicts?)
- **Acceptance Criteria Quality** (Are success criteria measurable?)
- **Scenario Coverage** (Are all flows/cases addressed?)
- **Edge Case Coverage** (Are boundary conditions defined?)
- **Non-Functional Requirements** (Performance, Security, Accessibility, etc. - are they specified?)
- **Dependencies & Assumptions** (Are they documented and validated?)
- **Ambiguities & Conflicts** (What needs clarification?)
**HOW TO WRITE CHECKLIST ITEMS - "Unit Tests for English"**:
**WRONG** (Testing implementation):
- "Verify landing page displays 3 episode cards"
- "Test hover states work on desktop"
- "Confirm logo click navigates home"
**CORRECT** (Testing requirements quality):
- "Are the exact number and layout of featured episodes specified?" [Completeness]
- "Is 'prominent display' quantified with specific sizing/positioning?" [Clarity]
- "Are hover state requirements consistent across all interactive elements?" [Consistency]
- "Are keyboard navigation requirements defined for all interactive UI?" [Coverage]
- "Is the fallback behavior specified when logo image fails to load?" [Edge Cases]
- "Are loading states defined for asynchronous episode data?" [Completeness]
- "Does the spec define visual hierarchy for competing UI elements?" [Clarity]
**ITEM STRUCTURE**:
Each item should follow this pattern:
- Question format asking about requirement quality
- Focus on what's WRITTEN (or not written) in the spec/plan
- Include quality dimension in brackets [Completeness/Clarity/Consistency/etc.]
- Reference spec section `[Spec §X.Y]` when checking existing requirements
- Use `[Gap]` marker when checking for missing requirements
**EXAMPLES BY QUALITY DIMENSION**:
Completeness:
- "Are error handling requirements defined for all API failure modes? [Gap]"
- "Are accessibility requirements specified for all interactive elements? [Completeness]"
- "Are mobile breakpoint requirements defined for responsive layouts? [Gap]"
Clarity:
- "Is 'fast loading' quantified with specific timing thresholds? [Clarity, Spec §NFR-2]"
- "Are 'related episodes' selection criteria explicitly defined? [Clarity, Spec §FR-5]"
- "Is 'prominent' defined with measurable visual properties? [Ambiguity, Spec §FR-4]"
Consistency:
- "Do navigation requirements align across all pages? [Consistency, Spec §FR-10]"
- "Are card component requirements consistent between landing and detail pages? [Consistency]"
Coverage:
- "Are requirements defined for zero-state scenarios (no episodes)? [Coverage, Edge Case]"
- "Are concurrent user interaction scenarios addressed? [Coverage, Gap]"
- "Are requirements specified for partial data loading failures? [Coverage, Exception Flow]"
Measurability:
- "Are visual hierarchy requirements measurable/testable? [Acceptance Criteria, Spec §FR-1]"
- "Can 'balanced visual weight' be objectively verified? [Measurability, Spec §FR-2]"
**Scenario Classification & Coverage** (Requirements Quality Focus):
- Check if requirements exist for: Primary, Alternate, Exception/Error, Recovery, Non-Functional scenarios
- For each scenario class, ask: "Are [scenario type] requirements complete, clear, and consistent?"
- If scenario class missing: "Are [scenario type] requirements intentionally excluded or missing? [Gap]"
- Include resilience/rollback when state mutation occurs: "Are rollback requirements defined for migration failures? [Gap]"
**Traceability Requirements**:
- MINIMUM: ≥80% of items MUST include at least one traceability reference
- Each item should reference: spec section `[Spec §X.Y]`, or use markers: `[Gap]`, `[Ambiguity]`, `[Conflict]`, `[Assumption]`
- If no ID system exists: "Is a requirement & acceptance criteria ID scheme established? [Traceability]"
**Surface & Resolve Issues** (Requirements Quality Problems):
Ask questions about the requirements themselves:
- Ambiguities: "Is the term 'fast' quantified with specific metrics? [Ambiguity, Spec §NFR-1]"
- Conflicts: "Do navigation requirements conflict between §FR-10 and §FR-10a? [Conflict]"
- Assumptions: "Is the assumption of 'always available podcast API' validated? [Assumption]"
- Dependencies: "Are external podcast API requirements documented? [Dependency, Gap]"
- Missing definitions: "Is 'visual hierarchy' defined with measurable criteria? [Gap]"
**Content Consolidation**:
- Soft cap: If raw candidate items > 40, prioritize by risk/impact
- Merge near-duplicates checking the same requirement aspect
- If >5 low-impact edge cases, create one item: "Are edge cases X, Y, Z addressed in requirements? [Coverage]"
**🚫 ABSOLUTELY PROHIBITED** - These make it an implementation test, not a requirements test:
- ❌ Any item starting with "Verify", "Test", "Confirm", "Check" + implementation behavior
- ❌ References to code execution, user actions, system behavior
- ❌ "Displays correctly", "works properly", "functions as expected"
- ❌ "Click", "navigate", "render", "load", "execute"
- ❌ Test cases, test plans, QA procedures
- ❌ Implementation details (frameworks, APIs, algorithms)
**✅ REQUIRED PATTERNS** - These test requirements quality:
- ✅ "Are [requirement type] defined/specified/documented for [scenario]?"
- ✅ "Is [vague term] quantified/clarified with specific criteria?"
- ✅ "Are requirements consistent between [section A] and [section B]?"
- ✅ "Can [requirement] be objectively measured/verified?"
- ✅ "Are [edge cases/scenarios] addressed in requirements?"
- ✅ "Does the spec define [missing aspect]?"
6. **Structure Reference**: Generate the checklist following the canonical template in `.specify/templates/checklist-template.md` for title, meta section, category headings, and ID formatting. If template is unavailable, use: H1 title, purpose/created meta lines, `##` category sections containing `- [ ] CHK### <requirement item>` lines with globally incrementing IDs starting at CHK001.
7. **Report**: Output full path to created checklist, item count, and remind user that each run creates a new file. Summarize:
- Focus areas selected
- Depth level
- Actor/timing
- Any explicit user-specified must-have items incorporated
**Important**: Each `/speckit.checklist` command invocation creates a checklist file using short, descriptive names unless file already exists. This allows:
- Multiple checklists of different types (e.g., `ux.md`, `test.md`, `security.md`)
- Simple, memorable filenames that indicate checklist purpose
- Easy identification and navigation in the `checklists/` folder
To avoid clutter, use descriptive types and clean up obsolete checklists when done.
## Example Checklist Types & Sample Items
**UX Requirements Quality:** `ux.md`
Sample items (testing the requirements, NOT the implementation):
- "Are visual hierarchy requirements defined with measurable criteria? [Clarity, Spec §FR-1]"
- "Is the number and positioning of UI elements explicitly specified? [Completeness, Spec §FR-1]"
- "Are interaction state requirements (hover, focus, active) consistently defined? [Consistency]"
- "Are accessibility requirements specified for all interactive elements? [Coverage, Gap]"
- "Is fallback behavior defined when images fail to load? [Edge Case, Gap]"
- "Can 'prominent display' be objectively measured? [Measurability, Spec §FR-4]"
**API Requirements Quality:** `api.md`
Sample items:
- "Are error response formats specified for all failure scenarios? [Completeness]"
- "Are rate limiting requirements quantified with specific thresholds? [Clarity]"
- "Are authentication requirements consistent across all endpoints? [Consistency]"
- "Are retry/timeout requirements defined for external dependencies? [Coverage, Gap]"
- "Is versioning strategy documented in requirements? [Gap]"
**Performance Requirements Quality:** `performance.md`
Sample items:
- "Are performance requirements quantified with specific metrics? [Clarity]"
- "Are performance targets defined for all critical user journeys? [Coverage]"
- "Are performance requirements under different load conditions specified? [Completeness]"
- "Can performance requirements be objectively measured? [Measurability]"
- "Are degradation requirements defined for high-load scenarios? [Edge Case, Gap]"
**Security Requirements Quality:** `security.md`
Sample items:
- "Are authentication requirements specified for all protected resources? [Coverage]"
- "Are data protection requirements defined for sensitive information? [Completeness]"
- "Is the threat model documented and requirements aligned to it? [Traceability]"
- "Are security requirements consistent with compliance obligations? [Consistency]"
- "Are security failure/breach response requirements defined? [Gap, Exception Flow]"
## Anti-Examples: What NOT To Do
**❌ WRONG - These test implementation, not requirements:**
```markdown
- [ ] CHK001 - Verify landing page displays 3 episode cards [Spec §FR-001]
- [ ] CHK002 - Test hover states work correctly on desktop [Spec §FR-003]
- [ ] CHK003 - Confirm logo click navigates to home page [Spec §FR-010]
- [ ] CHK004 - Check that related episodes section shows 3-5 items [Spec §FR-005]
```
**✅ CORRECT - These test requirements quality:**
```markdown
- [ ] CHK001 - Are the number and layout of featured episodes explicitly specified? [Completeness, Spec §FR-001]
- [ ] CHK002 - Are hover state requirements consistently defined for all interactive elements? [Consistency, Spec §FR-003]
- [ ] CHK003 - Are navigation requirements clear for all clickable brand elements? [Clarity, Spec §FR-010]
- [ ] CHK004 - Is the selection criteria for related episodes documented? [Gap, Spec §FR-005]
- [ ] CHK005 - Are loading state requirements defined for asynchronous episode data? [Gap]
- [ ] CHK006 - Can "visual hierarchy" requirements be objectively measured? [Measurability, Spec §FR-001]
```
**Key Differences:**
- Wrong: Tests if the system works correctly
- Correct: Tests if the requirements are written correctly
- Wrong: Verification of behavior
- Correct: Validation of requirement quality
- Wrong: "Does it do X?"
- Correct: "Is X clearly specified?"

View File

@@ -0,0 +1,181 @@
---
description: Identify underspecified areas in the current feature spec by asking up to 5 highly targeted clarification questions and encoding answers back into the spec.
handoffs:
- label: Build Technical Plan
agent: speckit.plan
prompt: Create a plan for the spec. I am building with...
---
## User Input
```text
$ARGUMENTS
```
You **MUST** consider the user input before proceeding (if not empty).
## Outline
Goal: Detect and reduce ambiguity or missing decision points in the active feature specification and record the clarifications directly in the spec file.
Note: This clarification workflow is expected to run (and be completed) BEFORE invoking `/speckit.plan`. If the user explicitly states they are skipping clarification (e.g., exploratory spike), you may proceed, but must warn that downstream rework risk increases.
Execution steps:
1. Run `.specify/scripts/powershell/check-prerequisites.ps1 -Json -PathsOnly` from repo root **once** (combined `--json --paths-only` mode / `-Json -PathsOnly`). Parse minimal JSON payload fields:
- `FEATURE_DIR`
- `FEATURE_SPEC`
- (Optionally capture `IMPL_PLAN`, `TASKS` for future chained flows.)
- If JSON parsing fails, abort and instruct user to re-run `/speckit.specify` or verify feature branch environment.
- For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
2. Load the current spec file. Perform a structured ambiguity & coverage scan using this taxonomy. For each category, mark status: Clear / Partial / Missing. Produce an internal coverage map used for prioritization (do not output raw map unless no questions will be asked).
Functional Scope & Behavior:
- Core user goals & success criteria
- Explicit out-of-scope declarations
- User roles / personas differentiation
Domain & Data Model:
- Entities, attributes, relationships
- Identity & uniqueness rules
- Lifecycle/state transitions
- Data volume / scale assumptions
Interaction & UX Flow:
- Critical user journeys / sequences
- Error/empty/loading states
- Accessibility or localization notes
Non-Functional Quality Attributes:
- Performance (latency, throughput targets)
- Scalability (horizontal/vertical, limits)
- Reliability & availability (uptime, recovery expectations)
- Observability (logging, metrics, tracing signals)
- Security & privacy (authN/Z, data protection, threat assumptions)
- Compliance / regulatory constraints (if any)
Integration & External Dependencies:
- External services/APIs and failure modes
- Data import/export formats
- Protocol/versioning assumptions
Edge Cases & Failure Handling:
- Negative scenarios
- Rate limiting / throttling
- Conflict resolution (e.g., concurrent edits)
Constraints & Tradeoffs:
- Technical constraints (language, storage, hosting)
- Explicit tradeoffs or rejected alternatives
Terminology & Consistency:
- Canonical glossary terms
- Avoided synonyms / deprecated terms
Completion Signals:
- Acceptance criteria testability
- Measurable Definition of Done style indicators
Misc / Placeholders:
- TODO markers / unresolved decisions
- Ambiguous adjectives ("robust", "intuitive") lacking quantification
For each category with Partial or Missing status, add a candidate question opportunity unless:
- Clarification would not materially change implementation or validation strategy
- Information is better deferred to planning phase (note internally)
3. Generate (internally) a prioritized queue of candidate clarification questions (maximum 5). Do NOT output them all at once. Apply these constraints:
- Maximum of 10 total questions across the whole session.
- Each question must be answerable with EITHER:
- A short multiplechoice selection (25 distinct, mutually exclusive options), OR
- A one-word / shortphrase answer (explicitly constrain: "Answer in <=5 words").
- Only include questions whose answers materially impact architecture, data modeling, task decomposition, test design, UX behavior, operational readiness, or compliance validation.
- Ensure category coverage balance: attempt to cover the highest impact unresolved categories first; avoid asking two low-impact questions when a single high-impact area (e.g., security posture) is unresolved.
- Exclude questions already answered, trivial stylistic preferences, or plan-level execution details (unless blocking correctness).
- Favor clarifications that reduce downstream rework risk or prevent misaligned acceptance tests.
- If more than 5 categories remain unresolved, select the top 5 by (Impact * Uncertainty) heuristic.
4. Sequential questioning loop (interactive):
- Present EXACTLY ONE question at a time.
- For multiplechoice questions:
- **Analyze all options** and determine the **most suitable option** based on:
- Best practices for the project type
- Common patterns in similar implementations
- Risk reduction (security, performance, maintainability)
- Alignment with any explicit project goals or constraints visible in the spec
- Present your **recommended option prominently** at the top with clear reasoning (1-2 sentences explaining why this is the best choice).
- Format as: `**Recommended:** Option [X] - <reasoning>`
- Then render all options as a Markdown table:
| Option | Description |
|--------|-------------|
| A | <Option A description> |
| B | <Option B description> |
| C | <Option C description> (add D/E as needed up to 5) |
| Short | Provide a different short answer (<=5 words) (Include only if free-form alternative is appropriate) |
- After the table, add: `You can reply with the option letter (e.g., "A"), accept the recommendation by saying "yes" or "recommended", or provide your own short answer.`
- For shortanswer style (no meaningful discrete options):
- Provide your **suggested answer** based on best practices and context.
- Format as: `**Suggested:** <your proposed answer> - <brief reasoning>`
- Then output: `Format: Short answer (<=5 words). You can accept the suggestion by saying "yes" or "suggested", or provide your own answer.`
- After the user answers:
- If the user replies with "yes", "recommended", or "suggested", use your previously stated recommendation/suggestion as the answer.
- Otherwise, validate the answer maps to one option or fits the <=5 word constraint.
- If ambiguous, ask for a quick disambiguation (count still belongs to same question; do not advance).
- Once satisfactory, record it in working memory (do not yet write to disk) and move to the next queued question.
- Stop asking further questions when:
- All critical ambiguities resolved early (remaining queued items become unnecessary), OR
- User signals completion ("done", "good", "no more"), OR
- You reach 5 asked questions.
- Never reveal future queued questions in advance.
- If no valid questions exist at start, immediately report no critical ambiguities.
5. Integration after EACH accepted answer (incremental update approach):
- Maintain in-memory representation of the spec (loaded once at start) plus the raw file contents.
- For the first integrated answer in this session:
- Ensure a `## Clarifications` section exists (create it just after the highest-level contextual/overview section per the spec template if missing).
- Under it, create (if not present) a `### Session YYYY-MM-DD` subheading for today.
- Append a bullet line immediately after acceptance: `- Q: <question> → A: <final answer>`.
- Then immediately apply the clarification to the most appropriate section(s):
- Functional ambiguity → Update or add a bullet in Functional Requirements.
- User interaction / actor distinction → Update User Stories or Actors subsection (if present) with clarified role, constraint, or scenario.
- Data shape / entities → Update Data Model (add fields, types, relationships) preserving ordering; note added constraints succinctly.
- Non-functional constraint → Add/modify measurable criteria in Non-Functional / Quality Attributes section (convert vague adjective to metric or explicit target).
- Edge case / negative flow → Add a new bullet under Edge Cases / Error Handling (or create such subsection if template provides placeholder for it).
- Terminology conflict → Normalize term across spec; retain original only if necessary by adding `(formerly referred to as "X")` once.
- If the clarification invalidates an earlier ambiguous statement, replace that statement instead of duplicating; leave no obsolete contradictory text.
- Save the spec file AFTER each integration to minimize risk of context loss (atomic overwrite).
- Preserve formatting: do not reorder unrelated sections; keep heading hierarchy intact.
- Keep each inserted clarification minimal and testable (avoid narrative drift).
6. Validation (performed after EACH write plus final pass):
- Clarifications session contains exactly one bullet per accepted answer (no duplicates).
- Total asked (accepted) questions ≤ 5.
- Updated sections contain no lingering vague placeholders the new answer was meant to resolve.
- No contradictory earlier statement remains (scan for now-invalid alternative choices removed).
- Markdown structure valid; only allowed new headings: `## Clarifications`, `### Session YYYY-MM-DD`.
- Terminology consistency: same canonical term used across all updated sections.
7. Write the updated spec back to `FEATURE_SPEC`.
8. Report completion (after questioning loop ends or early termination):
- Number of questions asked & answered.
- Path to updated spec.
- Sections touched (list names).
- Coverage summary table listing each taxonomy category with Status: Resolved (was Partial/Missing and addressed), Deferred (exceeds question quota or better suited for planning), Clear (already sufficient), Outstanding (still Partial/Missing but low impact).
- If any Outstanding or Deferred remain, recommend whether to proceed to `/speckit.plan` or run `/speckit.clarify` again later post-plan.
- Suggested next command.
Behavior rules:
- If no meaningful ambiguities found (or all potential questions would be low-impact), respond: "No critical ambiguities detected worth formal clarification." and suggest proceeding.
- If spec file missing, instruct user to run `/speckit.specify` first (do not create a new spec here).
- Never exceed 5 total asked questions (clarification retries for a single question do not count as new questions).
- Avoid speculative tech stack questions unless the absence blocks functional clarity.
- Respect user early termination signals ("stop", "done", "proceed").
- If no questions asked due to full coverage, output a compact coverage summary (all categories Clear) then suggest advancing.
- If quota reached with unresolved high-impact categories remaining, explicitly flag them under Deferred with rationale.
Context for prioritization: $ARGUMENTS

View File

@@ -0,0 +1,84 @@
---
description: Create or update the project constitution from interactive or provided principle inputs, ensuring all dependent templates stay in sync.
handoffs:
- label: Build Specification
agent: speckit.specify
prompt: Implement the feature specification based on the updated constitution. I want to build...
---
## User Input
```text
$ARGUMENTS
```
You **MUST** consider the user input before proceeding (if not empty).
## Outline
You are updating the project constitution at `.specify/memory/constitution.md`. This file is a TEMPLATE containing placeholder tokens in square brackets (e.g. `[PROJECT_NAME]`, `[PRINCIPLE_1_NAME]`). Your job is to (a) collect/derive concrete values, (b) fill the template precisely, and (c) propagate any amendments across dependent artifacts.
**Note**: If `.specify/memory/constitution.md` does not exist yet, it should have been initialized from `.specify/templates/constitution-template.md` during project setup. If it's missing, copy the template first.
Follow this execution flow:
1. Load the existing constitution at `.specify/memory/constitution.md`.
- Identify every placeholder token of the form `[ALL_CAPS_IDENTIFIER]`.
**IMPORTANT**: The user might require less or more principles than the ones used in the template. If a number is specified, respect that - follow the general template. You will update the doc accordingly.
2. Collect/derive values for placeholders:
- If user input (conversation) supplies a value, use it.
- Otherwise infer from existing repo context (README, docs, prior constitution versions if embedded).
- For governance dates: `RATIFICATION_DATE` is the original adoption date (if unknown ask or mark TODO), `LAST_AMENDED_DATE` is today if changes are made, otherwise keep previous.
- `CONSTITUTION_VERSION` must increment according to semantic versioning rules:
- MAJOR: Backward incompatible governance/principle removals or redefinitions.
- MINOR: New principle/section added or materially expanded guidance.
- PATCH: Clarifications, wording, typo fixes, non-semantic refinements.
- If version bump type ambiguous, propose reasoning before finalizing.
3. Draft the updated constitution content:
- Replace every placeholder with concrete text (no bracketed tokens left except intentionally retained template slots that the project has chosen not to define yet—explicitly justify any left).
- Preserve heading hierarchy and comments can be removed once replaced unless they still add clarifying guidance.
- Ensure each Principle section: succinct name line, paragraph (or bullet list) capturing nonnegotiable rules, explicit rationale if not obvious.
- Ensure Governance section lists amendment procedure, versioning policy, and compliance review expectations.
4. Consistency propagation checklist (convert prior checklist into active validations):
- Read `.specify/templates/plan-template.md` and ensure any "Constitution Check" or rules align with updated principles.
- Read `.specify/templates/spec-template.md` for scope/requirements alignment—update if constitution adds/removes mandatory sections or constraints.
- Read `.specify/templates/tasks-template.md` and ensure task categorization reflects new or removed principle-driven task types (e.g., observability, versioning, testing discipline).
- Read each command file in `.specify/templates/commands/*.md` (including this one) to verify no outdated references (agent-specific names like CLAUDE only) remain when generic guidance is required.
- Read any runtime guidance docs (e.g., `README.md`, `docs/quickstart.md`, or agent-specific guidance files if present). Update references to principles changed.
5. Produce a Sync Impact Report (prepend as an HTML comment at top of the constitution file after update):
- Version change: old → new
- List of modified principles (old title → new title if renamed)
- Added sections
- Removed sections
- Templates requiring updates (✅ updated / ⚠ pending) with file paths
- Follow-up TODOs if any placeholders intentionally deferred.
6. Validation before final output:
- No remaining unexplained bracket tokens.
- Version line matches report.
- Dates ISO format YYYY-MM-DD.
- Principles are declarative, testable, and free of vague language ("should" → replace with MUST/SHOULD rationale where appropriate).
7. Write the completed constitution back to `.specify/memory/constitution.md` (overwrite).
8. Output a final summary to the user with:
- New version and bump rationale.
- Any files flagged for manual follow-up.
- Suggested commit message (e.g., `docs: amend constitution to vX.Y.Z (principle additions + governance update)`).
Formatting & Style Requirements:
- Use Markdown headings exactly as in the template (do not demote/promote levels).
- Wrap long rationale lines to keep readability (<100 chars ideally) but do not hard enforce with awkward breaks.
- Keep a single blank line between sections.
- Avoid trailing whitespace.
If the user supplies partial updates (e.g., only one principle revision), still perform validation and version decision steps.
If critical info missing (e.g., ratification date truly unknown), insert `TODO(<FIELD_NAME>): explanation` and include in the Sync Impact Report under deferred items.
Do not create a new template; always operate on the existing `.specify/memory/constitution.md` file.

View File

@@ -0,0 +1,135 @@
---
description: Execute the implementation plan by processing and executing all tasks defined in tasks.md
---
## User Input
```text
$ARGUMENTS
```
You **MUST** consider the user input before proceeding (if not empty).
## Outline
1. Run `.specify/scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
2. **Check checklists status** (if FEATURE_DIR/checklists/ exists):
- Scan all checklist files in the checklists/ directory
- For each checklist, count:
- Total items: All lines matching `- [ ]` or `- [X]` or `- [x]`
- Completed items: Lines matching `- [X]` or `- [x]`
- Incomplete items: Lines matching `- [ ]`
- Create a status table:
```text
| Checklist | Total | Completed | Incomplete | Status |
|-----------|-------|-----------|------------|--------|
| ux.md | 12 | 12 | 0 | ✓ PASS |
| test.md | 8 | 5 | 3 | ✗ FAIL |
| security.md | 6 | 6 | 0 | ✓ PASS |
```
- Calculate overall status:
- **PASS**: All checklists have 0 incomplete items
- **FAIL**: One or more checklists have incomplete items
- **If any checklist is incomplete**:
- Display the table with incomplete item counts
- **STOP** and ask: "Some checklists are incomplete. Do you want to proceed with implementation anyway? (yes/no)"
- Wait for user response before continuing
- If user says "no" or "wait" or "stop", halt execution
- If user says "yes" or "proceed" or "continue", proceed to step 3
- **If all checklists are complete**:
- Display the table showing all checklists passed
- Automatically proceed to step 3
3. Load and analyze the implementation context:
- **REQUIRED**: Read tasks.md for the complete task list and execution plan
- **REQUIRED**: Read plan.md for tech stack, architecture, and file structure
- **IF EXISTS**: Read data-model.md for entities and relationships
- **IF EXISTS**: Read contracts/ for API specifications and test requirements
- **IF EXISTS**: Read research.md for technical decisions and constraints
- **IF EXISTS**: Read quickstart.md for integration scenarios
4. **Project Setup Verification**:
- **REQUIRED**: Create/verify ignore files based on actual project setup:
**Detection & Creation Logic**:
- Check if the following command succeeds to determine if the repository is a git repo (create/verify .gitignore if so):
```sh
git rev-parse --git-dir 2>/dev/null
```
- Check if Dockerfile* exists or Docker in plan.md → create/verify .dockerignore
- Check if .eslintrc* exists → create/verify .eslintignore
- Check if eslint.config.* exists → ensure the config's `ignores` entries cover required patterns
- Check if .prettierrc* exists → create/verify .prettierignore
- Check if .npmrc or package.json exists → create/verify .npmignore (if publishing)
- Check if terraform files (*.tf) exist → create/verify .terraformignore
- Check if .helmignore needed (helm charts present) → create/verify .helmignore
**If ignore file already exists**: Verify it contains essential patterns, append missing critical patterns only
**If ignore file missing**: Create with full pattern set for detected technology
**Common Patterns by Technology** (from plan.md tech stack):
- **Node.js/JavaScript/TypeScript**: `node_modules/`, `dist/`, `build/`, `*.log`, `.env*`
- **Python**: `__pycache__/`, `*.pyc`, `.venv/`, `venv/`, `dist/`, `*.egg-info/`
- **Java**: `target/`, `*.class`, `*.jar`, `.gradle/`, `build/`
- **C#/.NET**: `bin/`, `obj/`, `*.user`, `*.suo`, `packages/`
- **Go**: `*.exe`, `*.test`, `vendor/`, `*.out`
- **Ruby**: `.bundle/`, `log/`, `tmp/`, `*.gem`, `vendor/bundle/`
- **PHP**: `vendor/`, `*.log`, `*.cache`, `*.env`
- **Rust**: `target/`, `debug/`, `release/`, `*.rs.bk`, `*.rlib`, `*.prof*`, `.idea/`, `*.log`, `.env*`
- **Kotlin**: `build/`, `out/`, `.gradle/`, `.idea/`, `*.class`, `*.jar`, `*.iml`, `*.log`, `.env*`
- **C++**: `build/`, `bin/`, `obj/`, `out/`, `*.o`, `*.so`, `*.a`, `*.exe`, `*.dll`, `.idea/`, `*.log`, `.env*`
- **C**: `build/`, `bin/`, `obj/`, `out/`, `*.o`, `*.a`, `*.so`, `*.exe`, `Makefile`, `config.log`, `.idea/`, `*.log`, `.env*`
- **Swift**: `.build/`, `DerivedData/`, `*.swiftpm/`, `Packages/`
- **R**: `.Rproj.user/`, `.Rhistory`, `.RData`, `.Ruserdata`, `*.Rproj`, `packrat/`, `renv/`
- **Universal**: `.DS_Store`, `Thumbs.db`, `*.tmp`, `*.swp`, `.vscode/`, `.idea/`
**Tool-Specific Patterns**:
- **Docker**: `node_modules/`, `.git/`, `Dockerfile*`, `.dockerignore`, `*.log*`, `.env*`, `coverage/`
- **ESLint**: `node_modules/`, `dist/`, `build/`, `coverage/`, `*.min.js`
- **Prettier**: `node_modules/`, `dist/`, `build/`, `coverage/`, `package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`
- **Terraform**: `.terraform/`, `*.tfstate*`, `*.tfvars`, `.terraform.lock.hcl`
- **Kubernetes/k8s**: `*.secret.yaml`, `secrets/`, `.kube/`, `kubeconfig*`, `*.key`, `*.crt`
5. Parse tasks.md structure and extract:
- **Task phases**: Setup, Tests, Core, Integration, Polish
- **Task dependencies**: Sequential vs parallel execution rules
- **Task details**: ID, description, file paths, parallel markers [P]
- **Execution flow**: Order and dependency requirements
6. Execute implementation following the task plan:
- **Phase-by-phase execution**: Complete each phase before moving to the next
- **Respect dependencies**: Run sequential tasks in order, parallel tasks [P] can run together
- **Follow TDD approach**: Execute test tasks before their corresponding implementation tasks
- **File-based coordination**: Tasks affecting the same files must run sequentially
- **Validation checkpoints**: Verify each phase completion before proceeding
7. Implementation execution rules:
- **Setup first**: Initialize project structure, dependencies, configuration
- **Tests before code**: If you need to write tests for contracts, entities, and integration scenarios
- **Core development**: Implement models, services, CLI commands, endpoints
- **Integration work**: Database connections, middleware, logging, external services
- **Polish and validation**: Unit tests, performance optimization, documentation
8. Progress tracking and error handling:
- Report progress after each completed task
- Halt execution if any non-parallel task fails
- For parallel tasks [P], continue with successful tasks, report failed ones
- Provide clear error messages with context for debugging
- Suggest next steps if implementation cannot proceed
- **IMPORTANT** For completed tasks, make sure to mark the task off as [X] in the tasks file.
9. Completion validation:
- Verify all required tasks are completed
- Check that implemented features match the original specification
- Validate that tests pass and coverage meets requirements
- Confirm the implementation follows the technical plan
- Report final status with summary of completed work
Note: This command assumes a complete task breakdown exists in tasks.md. If tasks are incomplete or missing, suggest running `/speckit.tasks` first to regenerate the task list.

View File

@@ -0,0 +1,90 @@
---
description: Execute the implementation planning workflow using the plan template to generate design artifacts.
handoffs:
- label: Create Tasks
agent: speckit.tasks
prompt: Break the plan into tasks
send: true
- label: Create Checklist
agent: speckit.checklist
prompt: Create a checklist for the following domain...
---
## User Input
```text
$ARGUMENTS
```
You **MUST** consider the user input before proceeding (if not empty).
## Outline
1. **Setup**: Run `.specify/scripts/powershell/setup-plan.ps1 -Json` from repo root and parse JSON for FEATURE_SPEC, IMPL_PLAN, SPECS_DIR, BRANCH. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
2. **Load context**: Read FEATURE_SPEC and `.specify/memory/constitution.md`. Load IMPL_PLAN template (already copied).
3. **Execute plan workflow**: Follow the structure in IMPL_PLAN template to:
- Fill Technical Context (mark unknowns as "NEEDS CLARIFICATION")
- Fill Constitution Check section from constitution
- Evaluate gates (ERROR if violations unjustified)
- Phase 0: Generate research.md (resolve all NEEDS CLARIFICATION)
- Phase 1: Generate data-model.md, contracts/, quickstart.md
- Phase 1: Update agent context by running the agent script
- Re-evaluate Constitution Check post-design
4. **Stop and report**: Command ends after Phase 2 planning. Report branch, IMPL_PLAN path, and generated artifacts.
## Phases
### Phase 0: Outline & Research
1. **Extract unknowns from Technical Context** above:
- For each NEEDS CLARIFICATION → research task
- For each dependency → best practices task
- For each integration → patterns task
2. **Generate and dispatch research agents**:
```text
For each unknown in Technical Context:
Task: "Research {unknown} for {feature context}"
For each technology choice:
Task: "Find best practices for {tech} in {domain}"
```
3. **Consolidate findings** in `research.md` using format:
- Decision: [what was chosen]
- Rationale: [why chosen]
- Alternatives considered: [what else evaluated]
**Output**: research.md with all NEEDS CLARIFICATION resolved
### Phase 1: Design & Contracts
**Prerequisites:** `research.md` complete
1. **Extract entities from feature spec** → `data-model.md`:
- Entity name, fields, relationships
- Validation rules from requirements
- State transitions if applicable
2. **Define interface contracts** (if project has external interfaces) → `/contracts/`:
- Identify what interfaces the project exposes to users or other systems
- Document the contract format appropriate for the project type
- Examples: public APIs for libraries, command schemas for CLI tools, endpoints for web services, grammars for parsers, UI contracts for applications
- Skip if project is purely internal (build scripts, one-off tools, etc.)
3. **Agent context update**:
- Run `.specify/scripts/powershell/update-agent-context.ps1 -AgentType claude`
- These scripts detect which AI agent is in use
- Update the appropriate agent-specific context file
- Add only new technology from current plan
- Preserve manual additions between markers
**Output**: data-model.md, /contracts/*, quickstart.md, agent-specific file
## Key rules
- Use absolute paths
- ERROR on gate failures or unresolved clarifications

View File

@@ -0,0 +1,258 @@
---
description: Create or update the feature specification from a natural language feature description.
handoffs:
- label: Build Technical Plan
agent: speckit.plan
prompt: Create a plan for the spec. I am building with...
- label: Clarify Spec Requirements
agent: speckit.clarify
prompt: Clarify specification requirements
send: true
---
## User Input
```text
$ARGUMENTS
```
You **MUST** consider the user input before proceeding (if not empty).
## Outline
The text the user typed after `/speckit.specify` in the triggering message **is** the feature description. Assume you always have it available in this conversation even if `$ARGUMENTS` appears literally below. Do not ask the user to repeat it unless they provided an empty command.
Given that feature description, do this:
1. **Generate a concise short name** (2-4 words) for the branch:
- Analyze the feature description and extract the most meaningful keywords
- Create a 2-4 word short name that captures the essence of the feature
- Use action-noun format when possible (e.g., "add-user-auth", "fix-payment-bug")
- Preserve technical terms and acronyms (OAuth2, API, JWT, etc.)
- Keep it concise but descriptive enough to understand the feature at a glance
- Examples:
- "I want to add user authentication" → "user-auth"
- "Implement OAuth2 integration for the API" → "oauth2-api-integration"
- "Create a dashboard for analytics" → "analytics-dashboard"
- "Fix payment processing timeout bug" → "fix-payment-timeout"
2. **Check for existing branches before creating new one**:
a. First, fetch all remote branches to ensure we have the latest information:
```bash
git fetch --all --prune
```
b. Find the highest feature number across all sources for the short-name:
- Remote branches: `git ls-remote --heads origin | grep -E 'refs/heads/[0-9]+-<short-name>$'`
- Local branches: `git branch | grep -E '^[* ]*[0-9]+-<short-name>$'`
- Specs directories: Check for directories matching `specs/[0-9]+-<short-name>`
c. Determine the next available number:
- Extract all numbers from all three sources
- Find the highest number N
- Use N+1 for the new branch number
d. Run the script `.specify/scripts/powershell/create-new-feature.ps1 -Json "$ARGUMENTS"` with the calculated number and short-name:
- Pass `--number N+1` and `--short-name "your-short-name"` along with the feature description
- Bash example: `.specify/scripts/powershell/create-new-feature.ps1 -Json "$ARGUMENTS" --json --number 5 --short-name "user-auth" "Add user authentication"`
- PowerShell example: `.specify/scripts/powershell/create-new-feature.ps1 -Json "$ARGUMENTS" -Json -Number 5 -ShortName "user-auth" "Add user authentication"`
**IMPORTANT**:
- Check all three sources (remote branches, local branches, specs directories) to find the highest number
- Only match branches/directories with the exact short-name pattern
- If no existing branches/directories found with this short-name, start with number 1
- You must only ever run this script once per feature
- The JSON is provided in the terminal as output - always refer to it to get the actual content you're looking for
- The JSON output will contain BRANCH_NAME and SPEC_FILE paths
- For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot")
3. Load `.specify/templates/spec-template.md` to understand required sections.
4. Follow this execution flow:
1. Parse user description from Input
If empty: ERROR "No feature description provided"
2. Extract key concepts from description
Identify: actors, actions, data, constraints
3. For unclear aspects:
- Make informed guesses based on context and industry standards
- Only mark with [NEEDS CLARIFICATION: specific question] if:
- The choice significantly impacts feature scope or user experience
- Multiple reasonable interpretations exist with different implications
- No reasonable default exists
- **LIMIT: Maximum 3 [NEEDS CLARIFICATION] markers total**
- Prioritize clarifications by impact: scope > security/privacy > user experience > technical details
4. Fill User Scenarios & Testing section
If no clear user flow: ERROR "Cannot determine user scenarios"
5. Generate Functional Requirements
Each requirement must be testable
Use reasonable defaults for unspecified details (document assumptions in Assumptions section)
6. Define Success Criteria
Create measurable, technology-agnostic outcomes
Include both quantitative metrics (time, performance, volume) and qualitative measures (user satisfaction, task completion)
Each criterion must be verifiable without implementation details
7. Identify Key Entities (if data involved)
8. Return: SUCCESS (spec ready for planning)
5. Write the specification to SPEC_FILE using the template structure, replacing placeholders with concrete details derived from the feature description (arguments) while preserving section order and headings.
6. **Specification Quality Validation**: After writing the initial spec, validate it against quality criteria:
a. **Create Spec Quality Checklist**: Generate a checklist file at `FEATURE_DIR/checklists/requirements.md` using the checklist template structure with these validation items:
```markdown
# Specification Quality Checklist: [FEATURE NAME]
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: [DATE]
**Feature**: [Link to spec.md]
## Content Quality
- [ ] No implementation details (languages, frameworks, APIs)
- [ ] Focused on user value and business needs
- [ ] Written for non-technical stakeholders
- [ ] All mandatory sections completed
## Requirement Completeness
- [ ] No [NEEDS CLARIFICATION] markers remain
- [ ] Requirements are testable and unambiguous
- [ ] Success criteria are measurable
- [ ] Success criteria are technology-agnostic (no implementation details)
- [ ] All acceptance scenarios are defined
- [ ] Edge cases are identified
- [ ] Scope is clearly bounded
- [ ] Dependencies and assumptions identified
## Feature Readiness
- [ ] All functional requirements have clear acceptance criteria
- [ ] User scenarios cover primary flows
- [ ] Feature meets measurable outcomes defined in Success Criteria
- [ ] No implementation details leak into specification
## Notes
- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan`
```
b. **Run Validation Check**: Review the spec against each checklist item:
- For each item, determine if it passes or fails
- Document specific issues found (quote relevant spec sections)
c. **Handle Validation Results**:
- **If all items pass**: Mark checklist complete and proceed to step 6
- **If items fail (excluding [NEEDS CLARIFICATION])**:
1. List the failing items and specific issues
2. Update the spec to address each issue
3. Re-run validation until all items pass (max 3 iterations)
4. If still failing after 3 iterations, document remaining issues in checklist notes and warn user
- **If [NEEDS CLARIFICATION] markers remain**:
1. Extract all [NEEDS CLARIFICATION: ...] markers from the spec
2. **LIMIT CHECK**: If more than 3 markers exist, keep only the 3 most critical (by scope/security/UX impact) and make informed guesses for the rest
3. For each clarification needed (max 3), present options to user in this format:
```markdown
## Question [N]: [Topic]
**Context**: [Quote relevant spec section]
**What we need to know**: [Specific question from NEEDS CLARIFICATION marker]
**Suggested Answers**:
| Option | Answer | Implications |
|--------|--------|--------------|
| A | [First suggested answer] | [What this means for the feature] |
| B | [Second suggested answer] | [What this means for the feature] |
| C | [Third suggested answer] | [What this means for the feature] |
| Custom | Provide your own answer | [Explain how to provide custom input] |
**Your choice**: _[Wait for user response]_
```
4. **CRITICAL - Table Formatting**: Ensure markdown tables are properly formatted:
- Use consistent spacing with pipes aligned
- Each cell should have spaces around content: `| Content |` not `|Content|`
- Header separator must have at least 3 dashes: `|--------|`
- Test that the table renders correctly in markdown preview
5. Number questions sequentially (Q1, Q2, Q3 - max 3 total)
6. Present all questions together before waiting for responses
7. Wait for user to respond with their choices for all questions (e.g., "Q1: A, Q2: Custom - [details], Q3: B")
8. Update the spec by replacing each [NEEDS CLARIFICATION] marker with the user's selected or provided answer
9. Re-run validation after all clarifications are resolved
d. **Update Checklist**: After each validation iteration, update the checklist file with current pass/fail status
7. Report completion with branch name, spec file path, checklist results, and readiness for the next phase (`/speckit.clarify` or `/speckit.plan`).
**NOTE:** The script creates and checks out the new branch and initializes the spec file before writing.
## General Guidelines
## Quick Guidelines
- Focus on **WHAT** users need and **WHY**.
- Avoid HOW to implement (no tech stack, APIs, code structure).
- Written for business stakeholders, not developers.
- DO NOT create any checklists that are embedded in the spec. That will be a separate command.
### Section Requirements
- **Mandatory sections**: Must be completed for every feature
- **Optional sections**: Include only when relevant to the feature
- When a section doesn't apply, remove it entirely (don't leave as "N/A")
### For AI Generation
When creating this spec from a user prompt:
1. **Make informed guesses**: Use context, industry standards, and common patterns to fill gaps
2. **Document assumptions**: Record reasonable defaults in the Assumptions section
3. **Limit clarifications**: Maximum 3 [NEEDS CLARIFICATION] markers - use only for critical decisions that:
- Significantly impact feature scope or user experience
- Have multiple reasonable interpretations with different implications
- Lack any reasonable default
4. **Prioritize clarifications**: scope > security/privacy > user experience > technical details
5. **Think like a tester**: Every vague requirement should fail the "testable and unambiguous" checklist item
6. **Common areas needing clarification** (only if no reasonable default exists):
- Feature scope and boundaries (include/exclude specific use cases)
- User types and permissions (if multiple conflicting interpretations possible)
- Security/compliance requirements (when legally/financially significant)
**Examples of reasonable defaults** (don't ask about these):
- Data retention: Industry-standard practices for the domain
- Performance targets: Standard web/mobile app expectations unless specified
- Error handling: User-friendly messages with appropriate fallbacks
- Authentication method: Standard session-based or OAuth2 for web apps
- Integration patterns: Use project-appropriate patterns (REST/GraphQL for web services, function calls for libraries, CLI args for tools, etc.)
### Success Criteria Guidelines
Success criteria must be:
1. **Measurable**: Include specific metrics (time, percentage, count, rate)
2. **Technology-agnostic**: No mention of frameworks, languages, databases, or tools
3. **User-focused**: Describe outcomes from user/business perspective, not system internals
4. **Verifiable**: Can be tested/validated without knowing implementation details
**Good examples**:
- "Users can complete checkout in under 3 minutes"
- "System supports 10,000 concurrent users"
- "95% of searches return results in under 1 second"
- "Task completion rate improves by 40%"
**Bad examples** (implementation-focused):
- "API response time is under 200ms" (too technical, use "Users see results instantly")
- "Database can handle 1000 TPS" (implementation detail, use user-facing metric)
- "React components render efficiently" (framework-specific)
- "Redis cache hit rate above 80%" (technology-specific)

View File

@@ -0,0 +1,137 @@
---
description: Generate an actionable, dependency-ordered tasks.md for the feature based on available design artifacts.
handoffs:
- label: Analyze For Consistency
agent: speckit.analyze
prompt: Run a project analysis for consistency
send: true
- label: Implement Project
agent: speckit.implement
prompt: Start the implementation in phases
send: true
---
## User Input
```text
$ARGUMENTS
```
You **MUST** consider the user input before proceeding (if not empty).
## Outline
1. **Setup**: Run `.specify/scripts/powershell/check-prerequisites.ps1 -Json` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
2. **Load design documents**: Read from FEATURE_DIR:
- **Required**: plan.md (tech stack, libraries, structure), spec.md (user stories with priorities)
- **Optional**: data-model.md (entities), contracts/ (interface contracts), research.md (decisions), quickstart.md (test scenarios)
- Note: Not all projects have all documents. Generate tasks based on what's available.
3. **Execute task generation workflow**:
- Load plan.md and extract tech stack, libraries, project structure
- Load spec.md and extract user stories with their priorities (P1, P2, P3, etc.)
- If data-model.md exists: Extract entities and map to user stories
- If contracts/ exists: Map interface contracts to user stories
- If research.md exists: Extract decisions for setup tasks
- Generate tasks organized by user story (see Task Generation Rules below)
- Generate dependency graph showing user story completion order
- Create parallel execution examples per user story
- Validate task completeness (each user story has all needed tasks, independently testable)
4. **Generate tasks.md**: Use `.specify/templates/tasks-template.md` as structure, fill with:
- Correct feature name from plan.md
- Phase 1: Setup tasks (project initialization)
- Phase 2: Foundational tasks (blocking prerequisites for all user stories)
- Phase 3+: One phase per user story (in priority order from spec.md)
- Each phase includes: story goal, independent test criteria, tests (if requested), implementation tasks
- Final Phase: Polish & cross-cutting concerns
- All tasks must follow the strict checklist format (see Task Generation Rules below)
- Clear file paths for each task
- Dependencies section showing story completion order
- Parallel execution examples per story
- Implementation strategy section (MVP first, incremental delivery)
5. **Report**: Output path to generated tasks.md and summary:
- Total task count
- Task count per user story
- Parallel opportunities identified
- Independent test criteria for each story
- Suggested MVP scope (typically just User Story 1)
- Format validation: Confirm ALL tasks follow the checklist format (checkbox, ID, labels, file paths)
Context for task generation: $ARGUMENTS
The tasks.md should be immediately executable - each task must be specific enough that an LLM can complete it without additional context.
## Task Generation Rules
**CRITICAL**: Tasks MUST be organized by user story to enable independent implementation and testing.
**Tests are OPTIONAL**: Only generate test tasks if explicitly requested in the feature specification or if user requests TDD approach.
### Checklist Format (REQUIRED)
Every task MUST strictly follow this format:
```text
- [ ] [TaskID] [P?] [Story?] Description with file path
```
**Format Components**:
1. **Checkbox**: ALWAYS start with `- [ ]` (markdown checkbox)
2. **Task ID**: Sequential number (T001, T002, T003...) in execution order
3. **[P] marker**: Include ONLY if task is parallelizable (different files, no dependencies on incomplete tasks)
4. **[Story] label**: REQUIRED for user story phase tasks only
- Format: [US1], [US2], [US3], etc. (maps to user stories from spec.md)
- Setup phase: NO story label
- Foundational phase: NO story label
- User Story phases: MUST have story label
- Polish phase: NO story label
5. **Description**: Clear action with exact file path
**Examples**:
- ✅ CORRECT: `- [ ] T001 Create project structure per implementation plan`
- ✅ CORRECT: `- [ ] T005 [P] Implement authentication middleware in src/middleware/auth.py`
- ✅ CORRECT: `- [ ] T012 [P] [US1] Create User model in src/models/user.py`
- ✅ CORRECT: `- [ ] T014 [US1] Implement UserService in src/services/user_service.py`
- ❌ WRONG: `- [ ] Create User model` (missing ID and Story label)
- ❌ WRONG: `T001 [US1] Create model` (missing checkbox)
- ❌ WRONG: `- [ ] [US1] Create User model` (missing Task ID)
- ❌ WRONG: `- [ ] T001 [US1] Create model` (missing file path)
### Task Organization
1. **From User Stories (spec.md)** - PRIMARY ORGANIZATION:
- Each user story (P1, P2, P3...) gets its own phase
- Map all related components to their story:
- Models needed for that story
- Services needed for that story
- Interfaces/UI needed for that story
- If tests requested: Tests specific to that story
- Mark story dependencies (most stories should be independent)
2. **From Contracts**:
- Map each interface contract → to the user story it serves
- If tests requested: Each interface contract → contract test task [P] before implementation in that story's phase
3. **From Data Model**:
- Map each entity to the user story(ies) that need it
- If entity serves multiple stories: Put in earliest story or Setup phase
- Relationships → service layer tasks in appropriate story phase
4. **From Setup/Infrastructure**:
- Shared infrastructure → Setup phase (Phase 1)
- Foundational/blocking tasks → Foundational phase (Phase 2)
- Story-specific setup → within that story's phase
### Phase Structure
- **Phase 1**: Setup (project initialization)
- **Phase 2**: Foundational (blocking prerequisites - MUST complete before user stories)
- **Phase 3+**: User Stories in priority order (P1, P2, P3...)
- Within each story: Tests (if requested) → Models → Services → Endpoints → Integration
- Each phase should be a complete, independently testable increment
- **Final Phase**: Polish & Cross-Cutting Concerns

View File

@@ -0,0 +1,30 @@
---
description: Convert existing tasks into actionable, dependency-ordered GitHub issues for the feature based on available design artifacts.
tools: ['github/github-mcp-server/issue_write']
---
## User Input
```text
$ARGUMENTS
```
You **MUST** consider the user input before proceeding (if not empty).
## Outline
1. Run `.specify/scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
1. From the executed script, extract the path to **tasks**.
1. Get the Git remote by running:
```bash
git config --get remote.origin.url
```
> [!CAUTION]
> ONLY PROCEED TO NEXT STEPS IF THE REMOTE IS A GITHUB URL
1. For each task in the list, use the GitHub MCP server to create a new issue in the repository that is representative of the Git remote.
> [!CAUTION]
> UNDER NO CIRCUMSTANCES EVER CREATE ISSUES IN REPOSITORIES THAT DO NOT MATCH THE REMOTE URL

View File

@@ -0,0 +1,149 @@
<!--
Sync Impact Report
==================
Version change: (new) -> 1.0.0
Modified principles: N/A (initial ratification)
Added sections:
- Core Principles (5 principles)
- Technology Stack & Constraints
- Development Workflow
- Governance
Removed sections: N/A
Templates requiring updates:
- .specify/templates/plan-template.md — ✅ no changes needed (Constitution Check section is generic)
- .specify/templates/spec-template.md — ✅ no changes needed
- .specify/templates/tasks-template.md — ✅ no changes needed
- .specify/templates/checklist-template.md — ✅ no changes needed
- .specify/templates/agent-file-template.md — ✅ no changes needed
Follow-up TODOs: None
-->
# ImageApi Constitution
## Core Principles
### I. Layered Architecture
All features MUST follow the established layered architecture:
- **HTTP Layer** (`main.rs`, feature modules): Route handlers, request
parsing, response formatting. No direct database access.
- **Service Layer** (`files.rs`, `exif.rs`, `memories.rs`, etc.): Business
logic. No HTTP-specific types.
- **DAO Layer** (`database/` trait definitions): Trait-based data access
contracts. Every DAO MUST be defined as a trait to enable mock
implementations for testing.
- **Database Layer** (Diesel ORM, `schema.rs`): Concrete `Sqlite*Dao`
implementations. All queries traced with OpenTelemetry.
New features MUST NOT bypass layers (e.g., HTTP handlers MUST NOT
execute raw SQL). Actix actors are permitted for long-running async
work (video processing, file watching) but MUST interact with the
DAO layer through the established trait interfaces.
### II. Path Safety (NON-NEGOTIABLE)
All user-supplied file paths MUST be validated against `BASE_PATH`
using `is_valid_full_path()` before any filesystem operation. This
prevents directory traversal attacks.
- Paths stored in the database MUST be relative to `BASE_PATH`.
- Paths passed to external tools (ffmpeg, image processing) MUST be
fully resolved absolute paths.
- Extension detection MUST use the centralized helpers in
`file_types.rs` (case-insensitive). Manual string matching on
extensions is prohibited.
### III. Trait-Based Testability
All data access MUST go through trait-based DAOs so that every
handler and service can be tested with mock implementations.
- Each DAO trait MUST be defined in `src/database/` and require
`Sync + Send`.
- Mock DAOs for testing MUST live in `src/testhelpers.rs`.
- Integration tests against real SQLite MUST use in-memory databases
via `in_memory_db_connection()` from `database::test`.
- Handler tests MUST use `actix_web::test` utilities with JWT token
injection (using `Claims::valid_user()` and the `test_key` secret).
- New DAO implementations MUST include a `#[cfg(test)]` constructor
(e.g., `from_connection`) accepting an injected connection.
### IV. Environment-Driven Configuration
Server behavior MUST be controlled through environment variables
loaded from `.env` files. Hard-coded paths, URLs, or secrets are
prohibited.
- Required variables MUST call `.expect()` with a clear message at
startup so misconfiguration fails fast.
- Optional variables MUST use `.unwrap_or_else()` with sensible
defaults and be documented in `README.md`.
- Any new environment variable MUST be added to the README
environment section before the feature is considered complete.
### V. Observability
All database operations and HTTP handlers MUST be instrumented
with OpenTelemetry spans via the `trace_db_call` helper or
equivalent tracing macros.
- Release builds export traces to the configured OTLP endpoint.
- Debug builds use the basic logger.
- Prometheus metrics (`imageserver_image_total`,
`imageserver_video_total`) MUST be maintained for key counters.
- Errors MUST be logged at `error!` level with sufficient context
for debugging without reproducing the issue.
## Technology Stack & Constraints
- **Language**: Rust (stable toolchain, Cargo build system)
- **HTTP Framework**: Actix-web 4
- **ORM**: Diesel 2.2 with SQLite backend
- **Auth**: JWT (HS256) via `jsonwebtoken` crate, bcrypt password
hashing
- **Video Processing**: ffmpeg/ffprobe (CLI, must be on PATH)
- **Image Processing**: `image` crate for thumbnails, `kamadak-exif`
for EXIF extraction
- **Tracing**: OpenTelemetry with OTLP export (release),
basic logger (debug)
- **Testing**: `cargo test`, `actix_web::test`, in-memory SQLite
External dependencies (ffmpeg, Ollama) are optional runtime
requirements. The server MUST start and serve core functionality
(images, thumbnails, tags) without them. Features that depend on
optional services MUST degrade gracefully with logged warnings,
not panics.
## Development Workflow
- `cargo fmt` MUST pass before committing.
- `cargo clippy` warnings MUST be resolved or explicitly suppressed
with a justification comment.
- `cargo test` MUST pass with all tests green before merging to
master.
- Database schema changes MUST use Diesel migrations
(`diesel migration generate`), with hand-written SQL in `up.sql`
and `down.sql`, followed by `diesel print-schema` to regenerate
`schema.rs`.
- Features MUST be developed on named branches
(`###-feature-name`) and merged to master via pull request.
- File uploads MUST preserve existing files (append timestamp on
conflict, never overwrite).
## Governance
This constitution defines the non-negotiable architectural and
development standards for the ImageApi project. All code changes
MUST comply with these principles.
- **Amendments**: Any change to this constitution MUST be documented
with a version bump, rationale, and updated Sync Impact Report.
- **Versioning**: MAJOR for principle removals/redefinitions, MINOR
for new principles or material expansions, PATCH for wording
clarifications.
- **Compliance**: Pull request reviews SHOULD verify adherence to
these principles. The CLAUDE.md file provides runtime development
guidance and MUST remain consistent with this constitution.
**Version**: 1.0.0 | **Ratified**: 2026-02-26 | **Last Amended**: 2026-02-26

View File

@@ -0,0 +1,148 @@
#!/usr/bin/env pwsh
# Consolidated prerequisite checking script (PowerShell)
#
# This script provides unified prerequisite checking for Spec-Driven Development workflow.
# It replaces the functionality previously spread across multiple scripts.
#
# Usage: ./check-prerequisites.ps1 [OPTIONS]
#
# OPTIONS:
# -Json Output in JSON format
# -RequireTasks Require tasks.md to exist (for implementation phase)
# -IncludeTasks Include tasks.md in AVAILABLE_DOCS list
# -PathsOnly Only output path variables (no validation)
# -Help, -h Show help message
[CmdletBinding()]
param(
[switch]$Json,
[switch]$RequireTasks,
[switch]$IncludeTasks,
[switch]$PathsOnly,
[switch]$Help
)
$ErrorActionPreference = 'Stop'
# Show help if requested
if ($Help) {
Write-Output @"
Usage: check-prerequisites.ps1 [OPTIONS]
Consolidated prerequisite checking for Spec-Driven Development workflow.
OPTIONS:
-Json Output in JSON format
-RequireTasks Require tasks.md to exist (for implementation phase)
-IncludeTasks Include tasks.md in AVAILABLE_DOCS list
-PathsOnly Only output path variables (no prerequisite validation)
-Help, -h Show this help message
EXAMPLES:
# Check task prerequisites (plan.md required)
.\check-prerequisites.ps1 -Json
# Check implementation prerequisites (plan.md + tasks.md required)
.\check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks
# Get feature paths only (no validation)
.\check-prerequisites.ps1 -PathsOnly
"@
exit 0
}
# Source common functions
. "$PSScriptRoot/common.ps1"
# Get feature paths and validate branch
$paths = Get-FeaturePathsEnv
if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit:$paths.HAS_GIT)) {
exit 1
}
# If paths-only mode, output paths and exit (support combined -Json -PathsOnly)
if ($PathsOnly) {
if ($Json) {
[PSCustomObject]@{
REPO_ROOT = $paths.REPO_ROOT
BRANCH = $paths.CURRENT_BRANCH
FEATURE_DIR = $paths.FEATURE_DIR
FEATURE_SPEC = $paths.FEATURE_SPEC
IMPL_PLAN = $paths.IMPL_PLAN
TASKS = $paths.TASKS
} | ConvertTo-Json -Compress
} else {
Write-Output "REPO_ROOT: $($paths.REPO_ROOT)"
Write-Output "BRANCH: $($paths.CURRENT_BRANCH)"
Write-Output "FEATURE_DIR: $($paths.FEATURE_DIR)"
Write-Output "FEATURE_SPEC: $($paths.FEATURE_SPEC)"
Write-Output "IMPL_PLAN: $($paths.IMPL_PLAN)"
Write-Output "TASKS: $($paths.TASKS)"
}
exit 0
}
# Validate required directories and files
if (-not (Test-Path $paths.FEATURE_DIR -PathType Container)) {
Write-Output "ERROR: Feature directory not found: $($paths.FEATURE_DIR)"
Write-Output "Run /speckit.specify first to create the feature structure."
exit 1
}
if (-not (Test-Path $paths.IMPL_PLAN -PathType Leaf)) {
Write-Output "ERROR: plan.md not found in $($paths.FEATURE_DIR)"
Write-Output "Run /speckit.plan first to create the implementation plan."
exit 1
}
# Check for tasks.md if required
if ($RequireTasks -and -not (Test-Path $paths.TASKS -PathType Leaf)) {
Write-Output "ERROR: tasks.md not found in $($paths.FEATURE_DIR)"
Write-Output "Run /speckit.tasks first to create the task list."
exit 1
}
# Build list of available documents
$docs = @()
# Always check these optional docs
if (Test-Path $paths.RESEARCH) { $docs += 'research.md' }
if (Test-Path $paths.DATA_MODEL) { $docs += 'data-model.md' }
# Check contracts directory (only if it exists and has files)
if ((Test-Path $paths.CONTRACTS_DIR) -and (Get-ChildItem -Path $paths.CONTRACTS_DIR -ErrorAction SilentlyContinue | Select-Object -First 1)) {
$docs += 'contracts/'
}
if (Test-Path $paths.QUICKSTART) { $docs += 'quickstart.md' }
# Include tasks.md if requested and it exists
if ($IncludeTasks -and (Test-Path $paths.TASKS)) {
$docs += 'tasks.md'
}
# Output results
if ($Json) {
# JSON output
[PSCustomObject]@{
FEATURE_DIR = $paths.FEATURE_DIR
AVAILABLE_DOCS = $docs
} | ConvertTo-Json -Compress
} else {
# Text output
Write-Output "FEATURE_DIR:$($paths.FEATURE_DIR)"
Write-Output "AVAILABLE_DOCS:"
# Show status of each potential document
Test-FileExists -Path $paths.RESEARCH -Description 'research.md' | Out-Null
Test-FileExists -Path $paths.DATA_MODEL -Description 'data-model.md' | Out-Null
Test-DirHasFiles -Path $paths.CONTRACTS_DIR -Description 'contracts/' | Out-Null
Test-FileExists -Path $paths.QUICKSTART -Description 'quickstart.md' | Out-Null
if ($IncludeTasks) {
Test-FileExists -Path $paths.TASKS -Description 'tasks.md' | Out-Null
}
}

View File

@@ -0,0 +1,137 @@
#!/usr/bin/env pwsh
# Common PowerShell functions analogous to common.sh
function Get-RepoRoot {
try {
$result = git rev-parse --show-toplevel 2>$null
if ($LASTEXITCODE -eq 0) {
return $result
}
} catch {
# Git command failed
}
# Fall back to script location for non-git repos
return (Resolve-Path (Join-Path $PSScriptRoot "../../..")).Path
}
function Get-CurrentBranch {
# First check if SPECIFY_FEATURE environment variable is set
if ($env:SPECIFY_FEATURE) {
return $env:SPECIFY_FEATURE
}
# Then check git if available
try {
$result = git rev-parse --abbrev-ref HEAD 2>$null
if ($LASTEXITCODE -eq 0) {
return $result
}
} catch {
# Git command failed
}
# For non-git repos, try to find the latest feature directory
$repoRoot = Get-RepoRoot
$specsDir = Join-Path $repoRoot "specs"
if (Test-Path $specsDir) {
$latestFeature = ""
$highest = 0
Get-ChildItem -Path $specsDir -Directory | ForEach-Object {
if ($_.Name -match '^(\d{3})-') {
$num = [int]$matches[1]
if ($num -gt $highest) {
$highest = $num
$latestFeature = $_.Name
}
}
}
if ($latestFeature) {
return $latestFeature
}
}
# Final fallback
return "main"
}
function Test-HasGit {
try {
git rev-parse --show-toplevel 2>$null | Out-Null
return ($LASTEXITCODE -eq 0)
} catch {
return $false
}
}
function Test-FeatureBranch {
param(
[string]$Branch,
[bool]$HasGit = $true
)
# For non-git repos, we can't enforce branch naming but still provide output
if (-not $HasGit) {
Write-Warning "[specify] Warning: Git repository not detected; skipped branch validation"
return $true
}
if ($Branch -notmatch '^[0-9]{3}-') {
Write-Output "ERROR: Not on a feature branch. Current branch: $Branch"
Write-Output "Feature branches should be named like: 001-feature-name"
return $false
}
return $true
}
function Get-FeatureDir {
param([string]$RepoRoot, [string]$Branch)
Join-Path $RepoRoot "specs/$Branch"
}
function Get-FeaturePathsEnv {
$repoRoot = Get-RepoRoot
$currentBranch = Get-CurrentBranch
$hasGit = Test-HasGit
$featureDir = Get-FeatureDir -RepoRoot $repoRoot -Branch $currentBranch
[PSCustomObject]@{
REPO_ROOT = $repoRoot
CURRENT_BRANCH = $currentBranch
HAS_GIT = $hasGit
FEATURE_DIR = $featureDir
FEATURE_SPEC = Join-Path $featureDir 'spec.md'
IMPL_PLAN = Join-Path $featureDir 'plan.md'
TASKS = Join-Path $featureDir 'tasks.md'
RESEARCH = Join-Path $featureDir 'research.md'
DATA_MODEL = Join-Path $featureDir 'data-model.md'
QUICKSTART = Join-Path $featureDir 'quickstart.md'
CONTRACTS_DIR = Join-Path $featureDir 'contracts'
}
}
function Test-FileExists {
param([string]$Path, [string]$Description)
if (Test-Path -Path $Path -PathType Leaf) {
Write-Output "$Description"
return $true
} else {
Write-Output "$Description"
return $false
}
}
function Test-DirHasFiles {
param([string]$Path, [string]$Description)
if ((Test-Path -Path $Path -PathType Container) -and (Get-ChildItem -Path $Path -ErrorAction SilentlyContinue | Where-Object { -not $_.PSIsContainer } | Select-Object -First 1)) {
Write-Output "$Description"
return $true
} else {
Write-Output "$Description"
return $false
}
}

View File

@@ -0,0 +1,283 @@
#!/usr/bin/env pwsh
# Create a new feature
[CmdletBinding()]
param(
[switch]$Json,
[string]$ShortName,
[int]$Number = 0,
[switch]$Help,
[Parameter(ValueFromRemainingArguments = $true)]
[string[]]$FeatureDescription
)
$ErrorActionPreference = 'Stop'
# Show help if requested
if ($Help) {
Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-ShortName <name>] [-Number N] <feature description>"
Write-Host ""
Write-Host "Options:"
Write-Host " -Json Output in JSON format"
Write-Host " -ShortName <name> Provide a custom short name (2-4 words) for the branch"
Write-Host " -Number N Specify branch number manually (overrides auto-detection)"
Write-Host " -Help Show this help message"
Write-Host ""
Write-Host "Examples:"
Write-Host " ./create-new-feature.ps1 'Add user authentication system' -ShortName 'user-auth'"
Write-Host " ./create-new-feature.ps1 'Implement OAuth2 integration for API'"
exit 0
}
# Check if feature description provided
if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) {
Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-ShortName <name>] <feature description>"
exit 1
}
$featureDesc = ($FeatureDescription -join ' ').Trim()
# Resolve repository root. Prefer git information when available, but fall back
# to searching for repository markers so the workflow still functions in repositories that
# were initialized with --no-git.
function Find-RepositoryRoot {
param(
[string]$StartDir,
[string[]]$Markers = @('.git', '.specify')
)
$current = Resolve-Path $StartDir
while ($true) {
foreach ($marker in $Markers) {
if (Test-Path (Join-Path $current $marker)) {
return $current
}
}
$parent = Split-Path $current -Parent
if ($parent -eq $current) {
# Reached filesystem root without finding markers
return $null
}
$current = $parent
}
}
function Get-HighestNumberFromSpecs {
param([string]$SpecsDir)
$highest = 0
if (Test-Path $SpecsDir) {
Get-ChildItem -Path $SpecsDir -Directory | ForEach-Object {
if ($_.Name -match '^(\d+)') {
$num = [int]$matches[1]
if ($num -gt $highest) { $highest = $num }
}
}
}
return $highest
}
function Get-HighestNumberFromBranches {
param()
$highest = 0
try {
$branches = git branch -a 2>$null
if ($LASTEXITCODE -eq 0) {
foreach ($branch in $branches) {
# Clean branch name: remove leading markers and remote prefixes
$cleanBranch = $branch.Trim() -replace '^\*?\s+', '' -replace '^remotes/[^/]+/', ''
# Extract feature number if branch matches pattern ###-*
if ($cleanBranch -match '^(\d+)-') {
$num = [int]$matches[1]
if ($num -gt $highest) { $highest = $num }
}
}
}
} catch {
# If git command fails, return 0
Write-Verbose "Could not check Git branches: $_"
}
return $highest
}
function Get-NextBranchNumber {
param(
[string]$SpecsDir
)
# Fetch all remotes to get latest branch info (suppress errors if no remotes)
try {
git fetch --all --prune 2>$null | Out-Null
} catch {
# Ignore fetch errors
}
# Get highest number from ALL branches (not just matching short name)
$highestBranch = Get-HighestNumberFromBranches
# Get highest number from ALL specs (not just matching short name)
$highestSpec = Get-HighestNumberFromSpecs -SpecsDir $SpecsDir
# Take the maximum of both
$maxNum = [Math]::Max($highestBranch, $highestSpec)
# Return next number
return $maxNum + 1
}
function ConvertTo-CleanBranchName {
param([string]$Name)
return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', ''
}
$fallbackRoot = (Find-RepositoryRoot -StartDir $PSScriptRoot)
if (-not $fallbackRoot) {
Write-Error "Error: Could not determine repository root. Please run this script from within the repository."
exit 1
}
try {
$repoRoot = git rev-parse --show-toplevel 2>$null
if ($LASTEXITCODE -eq 0) {
$hasGit = $true
} else {
throw "Git not available"
}
} catch {
$repoRoot = $fallbackRoot
$hasGit = $false
}
Set-Location $repoRoot
$specsDir = Join-Path $repoRoot 'specs'
New-Item -ItemType Directory -Path $specsDir -Force | Out-Null
# Function to generate branch name with stop word filtering and length filtering
function Get-BranchName {
param([string]$Description)
# Common stop words to filter out
$stopWords = @(
'i', 'a', 'an', 'the', 'to', 'for', 'of', 'in', 'on', 'at', 'by', 'with', 'from',
'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had',
'do', 'does', 'did', 'will', 'would', 'should', 'could', 'can', 'may', 'might', 'must', 'shall',
'this', 'that', 'these', 'those', 'my', 'your', 'our', 'their',
'want', 'need', 'add', 'get', 'set'
)
# Convert to lowercase and extract words (alphanumeric only)
$cleanName = $Description.ToLower() -replace '[^a-z0-9\s]', ' '
$words = $cleanName -split '\s+' | Where-Object { $_ }
# Filter words: remove stop words and words shorter than 3 chars (unless they're uppercase acronyms in original)
$meaningfulWords = @()
foreach ($word in $words) {
# Skip stop words
if ($stopWords -contains $word) { continue }
# Keep words that are length >= 3 OR appear as uppercase in original (likely acronyms)
if ($word.Length -ge 3) {
$meaningfulWords += $word
} elseif ($Description -match "\b$($word.ToUpper())\b") {
# Keep short words if they appear as uppercase in original (likely acronyms)
$meaningfulWords += $word
}
}
# If we have meaningful words, use first 3-4 of them
if ($meaningfulWords.Count -gt 0) {
$maxWords = if ($meaningfulWords.Count -eq 4) { 4 } else { 3 }
$result = ($meaningfulWords | Select-Object -First $maxWords) -join '-'
return $result
} else {
# Fallback to original logic if no meaningful words found
$result = ConvertTo-CleanBranchName -Name $Description
$fallbackWords = ($result -split '-') | Where-Object { $_ } | Select-Object -First 3
return [string]::Join('-', $fallbackWords)
}
}
# Generate branch name
if ($ShortName) {
# Use provided short name, just clean it up
$branchSuffix = ConvertTo-CleanBranchName -Name $ShortName
} else {
# Generate from description with smart filtering
$branchSuffix = Get-BranchName -Description $featureDesc
}
# Determine branch number
if ($Number -eq 0) {
if ($hasGit) {
# Check existing branches on remotes
$Number = Get-NextBranchNumber -SpecsDir $specsDir
} else {
# Fall back to local directory check
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
}
}
$featureNum = ('{0:000}' -f $Number)
$branchName = "$featureNum-$branchSuffix"
# GitHub enforces a 244-byte limit on branch names
# Validate and truncate if necessary
$maxBranchLength = 244
if ($branchName.Length -gt $maxBranchLength) {
# Calculate how much we need to trim from suffix
# Account for: feature number (3) + hyphen (1) = 4 chars
$maxSuffixLength = $maxBranchLength - 4
# Truncate suffix
$truncatedSuffix = $branchSuffix.Substring(0, [Math]::Min($branchSuffix.Length, $maxSuffixLength))
# Remove trailing hyphen if truncation created one
$truncatedSuffix = $truncatedSuffix -replace '-$', ''
$originalBranchName = $branchName
$branchName = "$featureNum-$truncatedSuffix"
Write-Warning "[specify] Branch name exceeded GitHub's 244-byte limit"
Write-Warning "[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)"
Write-Warning "[specify] Truncated to: $branchName ($($branchName.Length) bytes)"
}
if ($hasGit) {
try {
git checkout -b $branchName | Out-Null
} catch {
Write-Warning "Failed to create git branch: $branchName"
}
} else {
Write-Warning "[specify] Warning: Git repository not detected; skipped branch creation for $branchName"
}
$featureDir = Join-Path $specsDir $branchName
New-Item -ItemType Directory -Path $featureDir -Force | Out-Null
$template = Join-Path $repoRoot '.specify/templates/spec-template.md'
$specFile = Join-Path $featureDir 'spec.md'
if (Test-Path $template) {
Copy-Item $template $specFile -Force
} else {
New-Item -ItemType File -Path $specFile | Out-Null
}
# Set the SPECIFY_FEATURE environment variable for the current session
$env:SPECIFY_FEATURE = $branchName
if ($Json) {
$obj = [PSCustomObject]@{
BRANCH_NAME = $branchName
SPEC_FILE = $specFile
FEATURE_NUM = $featureNum
HAS_GIT = $hasGit
}
$obj | ConvertTo-Json -Compress
} else {
Write-Output "BRANCH_NAME: $branchName"
Write-Output "SPEC_FILE: $specFile"
Write-Output "FEATURE_NUM: $featureNum"
Write-Output "HAS_GIT: $hasGit"
Write-Output "SPECIFY_FEATURE environment variable set to: $branchName"
}

View File

@@ -0,0 +1,61 @@
#!/usr/bin/env pwsh
# Setup implementation plan for a feature
[CmdletBinding()]
param(
[switch]$Json,
[switch]$Help
)
$ErrorActionPreference = 'Stop'
# Show help if requested
if ($Help) {
Write-Output "Usage: ./setup-plan.ps1 [-Json] [-Help]"
Write-Output " -Json Output results in JSON format"
Write-Output " -Help Show this help message"
exit 0
}
# Load common functions
. "$PSScriptRoot/common.ps1"
# Get all paths and variables from common functions
$paths = Get-FeaturePathsEnv
# Check if we're on a proper feature branch (only for git repos)
if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit $paths.HAS_GIT)) {
exit 1
}
# Ensure the feature directory exists
New-Item -ItemType Directory -Path $paths.FEATURE_DIR -Force | Out-Null
# Copy plan template if it exists, otherwise note it or create empty file
$template = Join-Path $paths.REPO_ROOT '.specify/templates/plan-template.md'
if (Test-Path $template) {
Copy-Item $template $paths.IMPL_PLAN -Force
Write-Output "Copied plan template to $($paths.IMPL_PLAN)"
} else {
Write-Warning "Plan template not found at $template"
# Create a basic plan file if template doesn't exist
New-Item -ItemType File -Path $paths.IMPL_PLAN -Force | Out-Null
}
# Output results
if ($Json) {
$result = [PSCustomObject]@{
FEATURE_SPEC = $paths.FEATURE_SPEC
IMPL_PLAN = $paths.IMPL_PLAN
SPECS_DIR = $paths.FEATURE_DIR
BRANCH = $paths.CURRENT_BRANCH
HAS_GIT = $paths.HAS_GIT
}
$result | ConvertTo-Json -Compress
} else {
Write-Output "FEATURE_SPEC: $($paths.FEATURE_SPEC)"
Write-Output "IMPL_PLAN: $($paths.IMPL_PLAN)"
Write-Output "SPECS_DIR: $($paths.FEATURE_DIR)"
Write-Output "BRANCH: $($paths.CURRENT_BRANCH)"
Write-Output "HAS_GIT: $($paths.HAS_GIT)"
}

View File

@@ -0,0 +1,452 @@
#!/usr/bin/env pwsh
<#!
.SYNOPSIS
Update agent context files with information from plan.md (PowerShell version)
.DESCRIPTION
Mirrors the behavior of scripts/bash/update-agent-context.sh:
1. Environment Validation
2. Plan Data Extraction
3. Agent File Management (create from template or update existing)
4. Content Generation (technology stack, recent changes, timestamp)
5. Multi-Agent Support (claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, roo, codebuddy, amp, shai, q, agy, bob, qodercli)
.PARAMETER AgentType
Optional agent key to update a single agent. If omitted, updates all existing agent files (creating a default Claude file if none exist).
.EXAMPLE
./update-agent-context.ps1 -AgentType claude
.EXAMPLE
./update-agent-context.ps1 # Updates all existing agent files
.NOTES
Relies on common helper functions in common.ps1
#>
param(
[Parameter(Position=0)]
[ValidateSet('claude','gemini','copilot','cursor-agent','qwen','opencode','codex','windsurf','kilocode','auggie','roo','codebuddy','amp','shai','q','agy','bob','qodercli','generic')]
[string]$AgentType
)
$ErrorActionPreference = 'Stop'
# Import common helpers
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
. (Join-Path $ScriptDir 'common.ps1')
# Acquire environment paths
$envData = Get-FeaturePathsEnv
$REPO_ROOT = $envData.REPO_ROOT
$CURRENT_BRANCH = $envData.CURRENT_BRANCH
$HAS_GIT = $envData.HAS_GIT
$IMPL_PLAN = $envData.IMPL_PLAN
$NEW_PLAN = $IMPL_PLAN
# Agent file paths
$CLAUDE_FILE = Join-Path $REPO_ROOT 'CLAUDE.md'
$GEMINI_FILE = Join-Path $REPO_ROOT 'GEMINI.md'
$COPILOT_FILE = Join-Path $REPO_ROOT '.github/agents/copilot-instructions.md'
$CURSOR_FILE = Join-Path $REPO_ROOT '.cursor/rules/specify-rules.mdc'
$QWEN_FILE = Join-Path $REPO_ROOT 'QWEN.md'
$AGENTS_FILE = Join-Path $REPO_ROOT 'AGENTS.md'
$WINDSURF_FILE = Join-Path $REPO_ROOT '.windsurf/rules/specify-rules.md'
$KILOCODE_FILE = Join-Path $REPO_ROOT '.kilocode/rules/specify-rules.md'
$AUGGIE_FILE = Join-Path $REPO_ROOT '.augment/rules/specify-rules.md'
$ROO_FILE = Join-Path $REPO_ROOT '.roo/rules/specify-rules.md'
$CODEBUDDY_FILE = Join-Path $REPO_ROOT 'CODEBUDDY.md'
$QODER_FILE = Join-Path $REPO_ROOT 'QODER.md'
$AMP_FILE = Join-Path $REPO_ROOT 'AGENTS.md'
$SHAI_FILE = Join-Path $REPO_ROOT 'SHAI.md'
$Q_FILE = Join-Path $REPO_ROOT 'AGENTS.md'
$AGY_FILE = Join-Path $REPO_ROOT '.agent/rules/specify-rules.md'
$BOB_FILE = Join-Path $REPO_ROOT 'AGENTS.md'
$TEMPLATE_FILE = Join-Path $REPO_ROOT '.specify/templates/agent-file-template.md'
# Parsed plan data placeholders
$script:NEW_LANG = ''
$script:NEW_FRAMEWORK = ''
$script:NEW_DB = ''
$script:NEW_PROJECT_TYPE = ''
function Write-Info {
param(
[Parameter(Mandatory=$true)]
[string]$Message
)
Write-Host "INFO: $Message"
}
function Write-Success {
param(
[Parameter(Mandatory=$true)]
[string]$Message
)
Write-Host "$([char]0x2713) $Message"
}
function Write-WarningMsg {
param(
[Parameter(Mandatory=$true)]
[string]$Message
)
Write-Warning $Message
}
function Write-Err {
param(
[Parameter(Mandatory=$true)]
[string]$Message
)
Write-Host "ERROR: $Message" -ForegroundColor Red
}
function Validate-Environment {
if (-not $CURRENT_BRANCH) {
Write-Err 'Unable to determine current feature'
if ($HAS_GIT) { Write-Info "Make sure you're on a feature branch" } else { Write-Info 'Set SPECIFY_FEATURE environment variable or create a feature first' }
exit 1
}
if (-not (Test-Path $NEW_PLAN)) {
Write-Err "No plan.md found at $NEW_PLAN"
Write-Info 'Ensure you are working on a feature with a corresponding spec directory'
if (-not $HAS_GIT) { Write-Info 'Use: $env:SPECIFY_FEATURE=your-feature-name or create a new feature first' }
exit 1
}
if (-not (Test-Path $TEMPLATE_FILE)) {
Write-Err "Template file not found at $TEMPLATE_FILE"
Write-Info 'Run specify init to scaffold .specify/templates, or add agent-file-template.md there.'
exit 1
}
}
function Extract-PlanField {
param(
[Parameter(Mandatory=$true)]
[string]$FieldPattern,
[Parameter(Mandatory=$true)]
[string]$PlanFile
)
if (-not (Test-Path $PlanFile)) { return '' }
# Lines like **Language/Version**: Python 3.12
$regex = "^\*\*$([Regex]::Escape($FieldPattern))\*\*: (.+)$"
Get-Content -LiteralPath $PlanFile -Encoding utf8 | ForEach-Object {
if ($_ -match $regex) {
$val = $Matches[1].Trim()
if ($val -notin @('NEEDS CLARIFICATION','N/A')) { return $val }
}
} | Select-Object -First 1
}
function Parse-PlanData {
param(
[Parameter(Mandatory=$true)]
[string]$PlanFile
)
if (-not (Test-Path $PlanFile)) { Write-Err "Plan file not found: $PlanFile"; return $false }
Write-Info "Parsing plan data from $PlanFile"
$script:NEW_LANG = Extract-PlanField -FieldPattern 'Language/Version' -PlanFile $PlanFile
$script:NEW_FRAMEWORK = Extract-PlanField -FieldPattern 'Primary Dependencies' -PlanFile $PlanFile
$script:NEW_DB = Extract-PlanField -FieldPattern 'Storage' -PlanFile $PlanFile
$script:NEW_PROJECT_TYPE = Extract-PlanField -FieldPattern 'Project Type' -PlanFile $PlanFile
if ($NEW_LANG) { Write-Info "Found language: $NEW_LANG" } else { Write-WarningMsg 'No language information found in plan' }
if ($NEW_FRAMEWORK) { Write-Info "Found framework: $NEW_FRAMEWORK" }
if ($NEW_DB -and $NEW_DB -ne 'N/A') { Write-Info "Found database: $NEW_DB" }
if ($NEW_PROJECT_TYPE) { Write-Info "Found project type: $NEW_PROJECT_TYPE" }
return $true
}
function Format-TechnologyStack {
param(
[Parameter(Mandatory=$false)]
[string]$Lang,
[Parameter(Mandatory=$false)]
[string]$Framework
)
$parts = @()
if ($Lang -and $Lang -ne 'NEEDS CLARIFICATION') { $parts += $Lang }
if ($Framework -and $Framework -notin @('NEEDS CLARIFICATION','N/A')) { $parts += $Framework }
if (-not $parts) { return '' }
return ($parts -join ' + ')
}
function Get-ProjectStructure {
param(
[Parameter(Mandatory=$false)]
[string]$ProjectType
)
if ($ProjectType -match 'web') { return "backend/`nfrontend/`ntests/" } else { return "src/`ntests/" }
}
function Get-CommandsForLanguage {
param(
[Parameter(Mandatory=$false)]
[string]$Lang
)
switch -Regex ($Lang) {
'Python' { return "cd src; pytest; ruff check ." }
'Rust' { return "cargo test; cargo clippy" }
'JavaScript|TypeScript' { return "npm test; npm run lint" }
default { return "# Add commands for $Lang" }
}
}
function Get-LanguageConventions {
param(
[Parameter(Mandatory=$false)]
[string]$Lang
)
if ($Lang) { "${Lang}: Follow standard conventions" } else { 'General: Follow standard conventions' }
}
function New-AgentFile {
param(
[Parameter(Mandatory=$true)]
[string]$TargetFile,
[Parameter(Mandatory=$true)]
[string]$ProjectName,
[Parameter(Mandatory=$true)]
[datetime]$Date
)
if (-not (Test-Path $TEMPLATE_FILE)) { Write-Err "Template not found at $TEMPLATE_FILE"; return $false }
$temp = New-TemporaryFile
Copy-Item -LiteralPath $TEMPLATE_FILE -Destination $temp -Force
$projectStructure = Get-ProjectStructure -ProjectType $NEW_PROJECT_TYPE
$commands = Get-CommandsForLanguage -Lang $NEW_LANG
$languageConventions = Get-LanguageConventions -Lang $NEW_LANG
$escaped_lang = $NEW_LANG
$escaped_framework = $NEW_FRAMEWORK
$escaped_branch = $CURRENT_BRANCH
$content = Get-Content -LiteralPath $temp -Raw -Encoding utf8
$content = $content -replace '\[PROJECT NAME\]',$ProjectName
$content = $content -replace '\[DATE\]',$Date.ToString('yyyy-MM-dd')
# Build the technology stack string safely
$techStackForTemplate = ""
if ($escaped_lang -and $escaped_framework) {
$techStackForTemplate = "- $escaped_lang + $escaped_framework ($escaped_branch)"
} elseif ($escaped_lang) {
$techStackForTemplate = "- $escaped_lang ($escaped_branch)"
} elseif ($escaped_framework) {
$techStackForTemplate = "- $escaped_framework ($escaped_branch)"
}
$content = $content -replace '\[EXTRACTED FROM ALL PLAN.MD FILES\]',$techStackForTemplate
# For project structure we manually embed (keep newlines)
$escapedStructure = [Regex]::Escape($projectStructure)
$content = $content -replace '\[ACTUAL STRUCTURE FROM PLANS\]',$escapedStructure
# Replace escaped newlines placeholder after all replacements
$content = $content -replace '\[ONLY COMMANDS FOR ACTIVE TECHNOLOGIES\]',$commands
$content = $content -replace '\[LANGUAGE-SPECIFIC, ONLY FOR LANGUAGES IN USE\]',$languageConventions
# Build the recent changes string safely
$recentChangesForTemplate = ""
if ($escaped_lang -and $escaped_framework) {
$recentChangesForTemplate = "- ${escaped_branch}: Added ${escaped_lang} + ${escaped_framework}"
} elseif ($escaped_lang) {
$recentChangesForTemplate = "- ${escaped_branch}: Added ${escaped_lang}"
} elseif ($escaped_framework) {
$recentChangesForTemplate = "- ${escaped_branch}: Added ${escaped_framework}"
}
$content = $content -replace '\[LAST 3 FEATURES AND WHAT THEY ADDED\]',$recentChangesForTemplate
# Convert literal \n sequences introduced by Escape to real newlines
$content = $content -replace '\\n',[Environment]::NewLine
$parent = Split-Path -Parent $TargetFile
if (-not (Test-Path $parent)) { New-Item -ItemType Directory -Path $parent | Out-Null }
Set-Content -LiteralPath $TargetFile -Value $content -NoNewline -Encoding utf8
Remove-Item $temp -Force
return $true
}
function Update-ExistingAgentFile {
param(
[Parameter(Mandatory=$true)]
[string]$TargetFile,
[Parameter(Mandatory=$true)]
[datetime]$Date
)
if (-not (Test-Path $TargetFile)) { return (New-AgentFile -TargetFile $TargetFile -ProjectName (Split-Path $REPO_ROOT -Leaf) -Date $Date) }
$techStack = Format-TechnologyStack -Lang $NEW_LANG -Framework $NEW_FRAMEWORK
$newTechEntries = @()
if ($techStack) {
$escapedTechStack = [Regex]::Escape($techStack)
if (-not (Select-String -Pattern $escapedTechStack -Path $TargetFile -Quiet)) {
$newTechEntries += "- $techStack ($CURRENT_BRANCH)"
}
}
if ($NEW_DB -and $NEW_DB -notin @('N/A','NEEDS CLARIFICATION')) {
$escapedDB = [Regex]::Escape($NEW_DB)
if (-not (Select-String -Pattern $escapedDB -Path $TargetFile -Quiet)) {
$newTechEntries += "- $NEW_DB ($CURRENT_BRANCH)"
}
}
$newChangeEntry = ''
if ($techStack) { $newChangeEntry = "- ${CURRENT_BRANCH}: Added ${techStack}" }
elseif ($NEW_DB -and $NEW_DB -notin @('N/A','NEEDS CLARIFICATION')) { $newChangeEntry = "- ${CURRENT_BRANCH}: Added ${NEW_DB}" }
$lines = Get-Content -LiteralPath $TargetFile -Encoding utf8
$output = New-Object System.Collections.Generic.List[string]
$inTech = $false; $inChanges = $false; $techAdded = $false; $changeAdded = $false; $existingChanges = 0
for ($i=0; $i -lt $lines.Count; $i++) {
$line = $lines[$i]
if ($line -eq '## Active Technologies') {
$output.Add($line)
$inTech = $true
continue
}
if ($inTech -and $line -match '^##\s') {
if (-not $techAdded -and $newTechEntries.Count -gt 0) { $newTechEntries | ForEach-Object { $output.Add($_) }; $techAdded = $true }
$output.Add($line); $inTech = $false; continue
}
if ($inTech -and [string]::IsNullOrWhiteSpace($line)) {
if (-not $techAdded -and $newTechEntries.Count -gt 0) { $newTechEntries | ForEach-Object { $output.Add($_) }; $techAdded = $true }
$output.Add($line); continue
}
if ($line -eq '## Recent Changes') {
$output.Add($line)
if ($newChangeEntry) { $output.Add($newChangeEntry); $changeAdded = $true }
$inChanges = $true
continue
}
if ($inChanges -and $line -match '^##\s') { $output.Add($line); $inChanges = $false; continue }
if ($inChanges -and $line -match '^- ') {
if ($existingChanges -lt 2) { $output.Add($line); $existingChanges++ }
continue
}
if ($line -match '\*\*Last updated\*\*: .*\d{4}-\d{2}-\d{2}') {
$output.Add(($line -replace '\d{4}-\d{2}-\d{2}',$Date.ToString('yyyy-MM-dd')))
continue
}
$output.Add($line)
}
# Post-loop check: if we're still in the Active Technologies section and haven't added new entries
if ($inTech -and -not $techAdded -and $newTechEntries.Count -gt 0) {
$newTechEntries | ForEach-Object { $output.Add($_) }
}
Set-Content -LiteralPath $TargetFile -Value ($output -join [Environment]::NewLine) -Encoding utf8
return $true
}
function Update-AgentFile {
param(
[Parameter(Mandatory=$true)]
[string]$TargetFile,
[Parameter(Mandatory=$true)]
[string]$AgentName
)
if (-not $TargetFile -or -not $AgentName) { Write-Err 'Update-AgentFile requires TargetFile and AgentName'; return $false }
Write-Info "Updating $AgentName context file: $TargetFile"
$projectName = Split-Path $REPO_ROOT -Leaf
$date = Get-Date
$dir = Split-Path -Parent $TargetFile
if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir | Out-Null }
if (-not (Test-Path $TargetFile)) {
if (New-AgentFile -TargetFile $TargetFile -ProjectName $projectName -Date $date) { Write-Success "Created new $AgentName context file" } else { Write-Err 'Failed to create new agent file'; return $false }
} else {
try {
if (Update-ExistingAgentFile -TargetFile $TargetFile -Date $date) { Write-Success "Updated existing $AgentName context file" } else { Write-Err 'Failed to update agent file'; return $false }
} catch {
Write-Err "Cannot access or update existing file: $TargetFile. $_"
return $false
}
}
return $true
}
function Update-SpecificAgent {
param(
[Parameter(Mandatory=$true)]
[string]$Type
)
switch ($Type) {
'claude' { Update-AgentFile -TargetFile $CLAUDE_FILE -AgentName 'Claude Code' }
'gemini' { Update-AgentFile -TargetFile $GEMINI_FILE -AgentName 'Gemini CLI' }
'copilot' { Update-AgentFile -TargetFile $COPILOT_FILE -AgentName 'GitHub Copilot' }
'cursor-agent' { Update-AgentFile -TargetFile $CURSOR_FILE -AgentName 'Cursor IDE' }
'qwen' { Update-AgentFile -TargetFile $QWEN_FILE -AgentName 'Qwen Code' }
'opencode' { Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'opencode' }
'codex' { Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'Codex CLI' }
'windsurf' { Update-AgentFile -TargetFile $WINDSURF_FILE -AgentName 'Windsurf' }
'kilocode' { Update-AgentFile -TargetFile $KILOCODE_FILE -AgentName 'Kilo Code' }
'auggie' { Update-AgentFile -TargetFile $AUGGIE_FILE -AgentName 'Auggie CLI' }
'roo' { Update-AgentFile -TargetFile $ROO_FILE -AgentName 'Roo Code' }
'codebuddy' { Update-AgentFile -TargetFile $CODEBUDDY_FILE -AgentName 'CodeBuddy CLI' }
'qodercli' { Update-AgentFile -TargetFile $QODER_FILE -AgentName 'Qoder CLI' }
'amp' { Update-AgentFile -TargetFile $AMP_FILE -AgentName 'Amp' }
'shai' { Update-AgentFile -TargetFile $SHAI_FILE -AgentName 'SHAI' }
'q' { Update-AgentFile -TargetFile $Q_FILE -AgentName 'Amazon Q Developer CLI' }
'agy' { Update-AgentFile -TargetFile $AGY_FILE -AgentName 'Antigravity' }
'bob' { Update-AgentFile -TargetFile $BOB_FILE -AgentName 'IBM Bob' }
'generic' { Write-Info 'Generic agent: no predefined context file. Use the agent-specific update script for your agent.' }
default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|q|agy|bob|qodercli|generic'; return $false }
}
}
function Update-AllExistingAgents {
$found = $false
$ok = $true
if (Test-Path $CLAUDE_FILE) { if (-not (Update-AgentFile -TargetFile $CLAUDE_FILE -AgentName 'Claude Code')) { $ok = $false }; $found = $true }
if (Test-Path $GEMINI_FILE) { if (-not (Update-AgentFile -TargetFile $GEMINI_FILE -AgentName 'Gemini CLI')) { $ok = $false }; $found = $true }
if (Test-Path $COPILOT_FILE) { if (-not (Update-AgentFile -TargetFile $COPILOT_FILE -AgentName 'GitHub Copilot')) { $ok = $false }; $found = $true }
if (Test-Path $CURSOR_FILE) { if (-not (Update-AgentFile -TargetFile $CURSOR_FILE -AgentName 'Cursor IDE')) { $ok = $false }; $found = $true }
if (Test-Path $QWEN_FILE) { if (-not (Update-AgentFile -TargetFile $QWEN_FILE -AgentName 'Qwen Code')) { $ok = $false }; $found = $true }
if (Test-Path $AGENTS_FILE) { if (-not (Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'Codex/opencode')) { $ok = $false }; $found = $true }
if (Test-Path $WINDSURF_FILE) { if (-not (Update-AgentFile -TargetFile $WINDSURF_FILE -AgentName 'Windsurf')) { $ok = $false }; $found = $true }
if (Test-Path $KILOCODE_FILE) { if (-not (Update-AgentFile -TargetFile $KILOCODE_FILE -AgentName 'Kilo Code')) { $ok = $false }; $found = $true }
if (Test-Path $AUGGIE_FILE) { if (-not (Update-AgentFile -TargetFile $AUGGIE_FILE -AgentName 'Auggie CLI')) { $ok = $false }; $found = $true }
if (Test-Path $ROO_FILE) { if (-not (Update-AgentFile -TargetFile $ROO_FILE -AgentName 'Roo Code')) { $ok = $false }; $found = $true }
if (Test-Path $CODEBUDDY_FILE) { if (-not (Update-AgentFile -TargetFile $CODEBUDDY_FILE -AgentName 'CodeBuddy CLI')) { $ok = $false }; $found = $true }
if (Test-Path $QODER_FILE) { if (-not (Update-AgentFile -TargetFile $QODER_FILE -AgentName 'Qoder CLI')) { $ok = $false }; $found = $true }
if (Test-Path $SHAI_FILE) { if (-not (Update-AgentFile -TargetFile $SHAI_FILE -AgentName 'SHAI')) { $ok = $false }; $found = $true }
if (Test-Path $Q_FILE) { if (-not (Update-AgentFile -TargetFile $Q_FILE -AgentName 'Amazon Q Developer CLI')) { $ok = $false }; $found = $true }
if (Test-Path $AGY_FILE) { if (-not (Update-AgentFile -TargetFile $AGY_FILE -AgentName 'Antigravity')) { $ok = $false }; $found = $true }
if (Test-Path $BOB_FILE) { if (-not (Update-AgentFile -TargetFile $BOB_FILE -AgentName 'IBM Bob')) { $ok = $false }; $found = $true }
if (-not $found) {
Write-Info 'No existing agent files found, creating default Claude file...'
if (-not (Update-AgentFile -TargetFile $CLAUDE_FILE -AgentName 'Claude Code')) { $ok = $false }
}
return $ok
}
function Print-Summary {
Write-Host ''
Write-Info 'Summary of changes:'
if ($NEW_LANG) { Write-Host " - Added language: $NEW_LANG" }
if ($NEW_FRAMEWORK) { Write-Host " - Added framework: $NEW_FRAMEWORK" }
if ($NEW_DB -and $NEW_DB -ne 'N/A') { Write-Host " - Added database: $NEW_DB" }
Write-Host ''
Write-Info 'Usage: ./update-agent-context.ps1 [-AgentType claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|q|agy|bob|qodercli|generic]'
}
function Main {
Validate-Environment
Write-Info "=== Updating agent context files for feature $CURRENT_BRANCH ==="
if (-not (Parse-PlanData -PlanFile $NEW_PLAN)) { Write-Err 'Failed to parse plan data'; exit 1 }
$success = $true
if ($AgentType) {
Write-Info "Updating specific agent: $AgentType"
if (-not (Update-SpecificAgent -Type $AgentType)) { $success = $false }
}
else {
Write-Info 'No agent specified, updating all existing agent files...'
if (-not (Update-AllExistingAgents)) { $success = $false }
}
Print-Summary
if ($success) { Write-Success 'Agent context update completed successfully'; exit 0 } else { Write-Err 'Agent context update completed with errors'; exit 1 }
}
Main

View File

@@ -0,0 +1,28 @@
# [PROJECT NAME] Development Guidelines
Auto-generated from all feature plans. Last updated: [DATE]
## Active Technologies
[EXTRACTED FROM ALL PLAN.MD FILES]
## Project Structure
```text
[ACTUAL STRUCTURE FROM PLANS]
```
## Commands
[ONLY COMMANDS FOR ACTIVE TECHNOLOGIES]
## Code Style
[LANGUAGE-SPECIFIC, ONLY FOR LANGUAGES IN USE]
## Recent Changes
[LAST 3 FEATURES AND WHAT THEY ADDED]
<!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END -->

View File

@@ -0,0 +1,40 @@
# [CHECKLIST TYPE] Checklist: [FEATURE NAME]
**Purpose**: [Brief description of what this checklist covers]
**Created**: [DATE]
**Feature**: [Link to spec.md or relevant documentation]
**Note**: This checklist is generated by the `/speckit.checklist` command based on feature context and requirements.
<!--
============================================================================
IMPORTANT: The checklist items below are SAMPLE ITEMS for illustration only.
The /speckit.checklist command MUST replace these with actual items based on:
- User's specific checklist request
- Feature requirements from spec.md
- Technical context from plan.md
- Implementation details from tasks.md
DO NOT keep these sample items in the generated checklist file.
============================================================================
-->
## [Category 1]
- [ ] CHK001 First checklist item with clear action
- [ ] CHK002 Second checklist item
- [ ] CHK003 Third checklist item
## [Category 2]
- [ ] CHK004 Another category item
- [ ] CHK005 Item with specific criteria
- [ ] CHK006 Final item in this category
## Notes
- Check items off as completed: `[x]`
- Add comments or findings inline
- Link to relevant resources or documentation
- Items are numbered sequentially for easy reference

View File

@@ -0,0 +1,50 @@
# [PROJECT_NAME] Constitution
<!-- Example: Spec Constitution, TaskFlow Constitution, etc. -->
## Core Principles
### [PRINCIPLE_1_NAME]
<!-- Example: I. Library-First -->
[PRINCIPLE_1_DESCRIPTION]
<!-- Example: Every feature starts as a standalone library; Libraries must be self-contained, independently testable, documented; Clear purpose required - no organizational-only libraries -->
### [PRINCIPLE_2_NAME]
<!-- Example: II. CLI Interface -->
[PRINCIPLE_2_DESCRIPTION]
<!-- Example: Every library exposes functionality via CLI; Text in/out protocol: stdin/args → stdout, errors → stderr; Support JSON + human-readable formats -->
### [PRINCIPLE_3_NAME]
<!-- Example: III. Test-First (NON-NEGOTIABLE) -->
[PRINCIPLE_3_DESCRIPTION]
<!-- Example: TDD mandatory: Tests written → User approved → Tests fail → Then implement; Red-Green-Refactor cycle strictly enforced -->
### [PRINCIPLE_4_NAME]
<!-- Example: IV. Integration Testing -->
[PRINCIPLE_4_DESCRIPTION]
<!-- Example: Focus areas requiring integration tests: New library contract tests, Contract changes, Inter-service communication, Shared schemas -->
### [PRINCIPLE_5_NAME]
<!-- Example: V. Observability, VI. Versioning & Breaking Changes, VII. Simplicity -->
[PRINCIPLE_5_DESCRIPTION]
<!-- Example: Text I/O ensures debuggability; Structured logging required; Or: MAJOR.MINOR.BUILD format; Or: Start simple, YAGNI principles -->
## [SECTION_2_NAME]
<!-- Example: Additional Constraints, Security Requirements, Performance Standards, etc. -->
[SECTION_2_CONTENT]
<!-- Example: Technology stack requirements, compliance standards, deployment policies, etc. -->
## [SECTION_3_NAME]
<!-- Example: Development Workflow, Review Process, Quality Gates, etc. -->
[SECTION_3_CONTENT]
<!-- Example: Code review requirements, testing gates, deployment approval process, etc. -->
## Governance
<!-- Example: Constitution supersedes all other practices; Amendments require documentation, approval, migration plan -->
[GOVERNANCE_RULES]
<!-- Example: All PRs/reviews must verify compliance; Complexity must be justified; Use [GUIDANCE_FILE] for runtime development guidance -->
**Version**: [CONSTITUTION_VERSION] | **Ratified**: [RATIFICATION_DATE] | **Last Amended**: [LAST_AMENDED_DATE]
<!-- Example: Version: 2.1.1 | Ratified: 2025-06-13 | Last Amended: 2025-07-16 -->

View File

@@ -0,0 +1,104 @@
# Implementation Plan: [FEATURE]
**Branch**: `[###-feature-name]` | **Date**: [DATE] | **Spec**: [link]
**Input**: Feature specification from `/specs/[###-feature-name]/spec.md`
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/templates/plan-template.md` for the execution workflow.
## Summary
[Extract from feature spec: primary requirement + technical approach from research]
## Technical Context
<!--
ACTION REQUIRED: Replace the content in this section with the technical details
for the project. The structure here is presented in advisory capacity to guide
the iteration process.
-->
**Language/Version**: [e.g., Python 3.11, Swift 5.9, Rust 1.75 or NEEDS CLARIFICATION]
**Primary Dependencies**: [e.g., FastAPI, UIKit, LLVM or NEEDS CLARIFICATION]
**Storage**: [if applicable, e.g., PostgreSQL, CoreData, files or N/A]
**Testing**: [e.g., pytest, XCTest, cargo test or NEEDS CLARIFICATION]
**Target Platform**: [e.g., Linux server, iOS 15+, WASM or NEEDS CLARIFICATION]
**Project Type**: [e.g., library/cli/web-service/mobile-app/compiler/desktop-app or NEEDS CLARIFICATION]
**Performance Goals**: [domain-specific, e.g., 1000 req/s, 10k lines/sec, 60 fps or NEEDS CLARIFICATION]
**Constraints**: [domain-specific, e.g., <200ms p95, <100MB memory, offline-capable or NEEDS CLARIFICATION]
**Scale/Scope**: [domain-specific, e.g., 10k users, 1M LOC, 50 screens or NEEDS CLARIFICATION]
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
[Gates determined based on constitution file]
## Project Structure
### Documentation (this feature)
```text
specs/[###-feature]/
├── plan.md # This file (/speckit.plan command output)
├── research.md # Phase 0 output (/speckit.plan command)
├── data-model.md # Phase 1 output (/speckit.plan command)
├── quickstart.md # Phase 1 output (/speckit.plan command)
├── contracts/ # Phase 1 output (/speckit.plan command)
└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan)
```
### Source Code (repository root)
<!--
ACTION REQUIRED: Replace the placeholder tree below with the concrete layout
for this feature. Delete unused options and expand the chosen structure with
real paths (e.g., apps/admin, packages/something). The delivered plan must
not include Option labels.
-->
```text
# [REMOVE IF UNUSED] Option 1: Single project (DEFAULT)
src/
├── models/
├── services/
├── cli/
└── lib/
tests/
├── contract/
├── integration/
└── unit/
# [REMOVE IF UNUSED] Option 2: Web application (when "frontend" + "backend" detected)
backend/
├── src/
│ ├── models/
│ ├── services/
│ └── api/
└── tests/
frontend/
├── src/
│ ├── components/
│ ├── pages/
│ └── services/
└── tests/
# [REMOVE IF UNUSED] Option 3: Mobile + API (when "iOS/Android" detected)
api/
└── [same as backend above]
ios/ or android/
└── [platform-specific structure: feature modules, UI flows, platform tests]
```
**Structure Decision**: [Document the selected structure and reference the real
directories captured above]
## Complexity Tracking
> **Fill ONLY if Constitution Check has violations that must be justified**
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| [e.g., 4th project] | [current need] | [why 3 projects insufficient] |
| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] |

View File

@@ -0,0 +1,115 @@
# Feature Specification: [FEATURE NAME]
**Feature Branch**: `[###-feature-name]`
**Created**: [DATE]
**Status**: Draft
**Input**: User description: "$ARGUMENTS"
## User Scenarios & Testing *(mandatory)*
<!--
IMPORTANT: User stories should be PRIORITIZED as user journeys ordered by importance.
Each user story/journey must be INDEPENDENTLY TESTABLE - meaning if you implement just ONE of them,
you should still have a viable MVP (Minimum Viable Product) that delivers value.
Assign priorities (P1, P2, P3, etc.) to each story, where P1 is the most critical.
Think of each story as a standalone slice of functionality that can be:
- Developed independently
- Tested independently
- Deployed independently
- Demonstrated to users independently
-->
### User Story 1 - [Brief Title] (Priority: P1)
[Describe this user journey in plain language]
**Why this priority**: [Explain the value and why it has this priority level]
**Independent Test**: [Describe how this can be tested independently - e.g., "Can be fully tested by [specific action] and delivers [specific value]"]
**Acceptance Scenarios**:
1. **Given** [initial state], **When** [action], **Then** [expected outcome]
2. **Given** [initial state], **When** [action], **Then** [expected outcome]
---
### User Story 2 - [Brief Title] (Priority: P2)
[Describe this user journey in plain language]
**Why this priority**: [Explain the value and why it has this priority level]
**Independent Test**: [Describe how this can be tested independently]
**Acceptance Scenarios**:
1. **Given** [initial state], **When** [action], **Then** [expected outcome]
---
### User Story 3 - [Brief Title] (Priority: P3)
[Describe this user journey in plain language]
**Why this priority**: [Explain the value and why it has this priority level]
**Independent Test**: [Describe how this can be tested independently]
**Acceptance Scenarios**:
1. **Given** [initial state], **When** [action], **Then** [expected outcome]
---
[Add more user stories as needed, each with an assigned priority]
### Edge Cases
<!--
ACTION REQUIRED: The content in this section represents placeholders.
Fill them out with the right edge cases.
-->
- What happens when [boundary condition]?
- How does system handle [error scenario]?
## Requirements *(mandatory)*
<!--
ACTION REQUIRED: The content in this section represents placeholders.
Fill them out with the right functional requirements.
-->
### Functional Requirements
- **FR-001**: System MUST [specific capability, e.g., "allow users to create accounts"]
- **FR-002**: System MUST [specific capability, e.g., "validate email addresses"]
- **FR-003**: Users MUST be able to [key interaction, e.g., "reset their password"]
- **FR-004**: System MUST [data requirement, e.g., "persist user preferences"]
- **FR-005**: System MUST [behavior, e.g., "log all security events"]
*Example of marking unclear requirements:*
- **FR-006**: System MUST authenticate users via [NEEDS CLARIFICATION: auth method not specified - email/password, SSO, OAuth?]
- **FR-007**: System MUST retain user data for [NEEDS CLARIFICATION: retention period not specified]
### Key Entities *(include if feature involves data)*
- **[Entity 1]**: [What it represents, key attributes without implementation]
- **[Entity 2]**: [What it represents, relationships to other entities]
## Success Criteria *(mandatory)*
<!--
ACTION REQUIRED: Define measurable success criteria.
These must be technology-agnostic and measurable.
-->
### Measurable Outcomes
- **SC-001**: [Measurable metric, e.g., "Users can complete account creation in under 2 minutes"]
- **SC-002**: [Measurable metric, e.g., "System handles 1000 concurrent users without degradation"]
- **SC-003**: [User satisfaction metric, e.g., "90% of users successfully complete primary task on first attempt"]
- **SC-004**: [Business metric, e.g., "Reduce support tickets related to [X] by 50%"]

View File

@@ -0,0 +1,251 @@
---
description: "Task list template for feature implementation"
---
# Tasks: [FEATURE NAME]
**Input**: Design documents from `/specs/[###-feature-name]/`
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/
**Tests**: The examples below include test tasks. Tests are OPTIONAL - only include them if explicitly requested in the feature specification.
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
## Format: `[ID] [P?] [Story] Description`
- **[P]**: Can run in parallel (different files, no dependencies)
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
- Include exact file paths in descriptions
## Path Conventions
- **Single project**: `src/`, `tests/` at repository root
- **Web app**: `backend/src/`, `frontend/src/`
- **Mobile**: `api/src/`, `ios/src/` or `android/src/`
- Paths shown below assume single project - adjust based on plan.md structure
<!--
============================================================================
IMPORTANT: The tasks below are SAMPLE TASKS for illustration purposes only.
The /speckit.tasks command MUST replace these with actual tasks based on:
- User stories from spec.md (with their priorities P1, P2, P3...)
- Feature requirements from plan.md
- Entities from data-model.md
- Endpoints from contracts/
Tasks MUST be organized by user story so each story can be:
- Implemented independently
- Tested independently
- Delivered as an MVP increment
DO NOT keep these sample tasks in the generated tasks.md file.
============================================================================
-->
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Project initialization and basic structure
- [ ] T001 Create project structure per implementation plan
- [ ] T002 Initialize [language] project with [framework] dependencies
- [ ] T003 [P] Configure linting and formatting tools
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Core infrastructure that MUST be complete before ANY user story can be implemented
**⚠️ CRITICAL**: No user story work can begin until this phase is complete
Examples of foundational tasks (adjust based on your project):
- [ ] T004 Setup database schema and migrations framework
- [ ] T005 [P] Implement authentication/authorization framework
- [ ] T006 [P] Setup API routing and middleware structure
- [ ] T007 Create base models/entities that all stories depend on
- [ ] T008 Configure error handling and logging infrastructure
- [ ] T009 Setup environment configuration management
**Checkpoint**: Foundation ready - user story implementation can now begin in parallel
---
## Phase 3: User Story 1 - [Title] (Priority: P1) 🎯 MVP
**Goal**: [Brief description of what this story delivers]
**Independent Test**: [How to verify this story works on its own]
### Tests for User Story 1 (OPTIONAL - only if tests requested) ⚠️
> **NOTE: Write these tests FIRST, ensure they FAIL before implementation**
- [ ] T010 [P] [US1] Contract test for [endpoint] in tests/contract/test_[name].py
- [ ] T011 [P] [US1] Integration test for [user journey] in tests/integration/test_[name].py
### Implementation for User Story 1
- [ ] T012 [P] [US1] Create [Entity1] model in src/models/[entity1].py
- [ ] T013 [P] [US1] Create [Entity2] model in src/models/[entity2].py
- [ ] T014 [US1] Implement [Service] in src/services/[service].py (depends on T012, T013)
- [ ] T015 [US1] Implement [endpoint/feature] in src/[location]/[file].py
- [ ] T016 [US1] Add validation and error handling
- [ ] T017 [US1] Add logging for user story 1 operations
**Checkpoint**: At this point, User Story 1 should be fully functional and testable independently
---
## Phase 4: User Story 2 - [Title] (Priority: P2)
**Goal**: [Brief description of what this story delivers]
**Independent Test**: [How to verify this story works on its own]
### Tests for User Story 2 (OPTIONAL - only if tests requested) ⚠️
- [ ] T018 [P] [US2] Contract test for [endpoint] in tests/contract/test_[name].py
- [ ] T019 [P] [US2] Integration test for [user journey] in tests/integration/test_[name].py
### Implementation for User Story 2
- [ ] T020 [P] [US2] Create [Entity] model in src/models/[entity].py
- [ ] T021 [US2] Implement [Service] in src/services/[service].py
- [ ] T022 [US2] Implement [endpoint/feature] in src/[location]/[file].py
- [ ] T023 [US2] Integrate with User Story 1 components (if needed)
**Checkpoint**: At this point, User Stories 1 AND 2 should both work independently
---
## Phase 5: User Story 3 - [Title] (Priority: P3)
**Goal**: [Brief description of what this story delivers]
**Independent Test**: [How to verify this story works on its own]
### Tests for User Story 3 (OPTIONAL - only if tests requested) ⚠️
- [ ] T024 [P] [US3] Contract test for [endpoint] in tests/contract/test_[name].py
- [ ] T025 [P] [US3] Integration test for [user journey] in tests/integration/test_[name].py
### Implementation for User Story 3
- [ ] T026 [P] [US3] Create [Entity] model in src/models/[entity].py
- [ ] T027 [US3] Implement [Service] in src/services/[service].py
- [ ] T028 [US3] Implement [endpoint/feature] in src/[location]/[file].py
**Checkpoint**: All user stories should now be independently functional
---
[Add more user story phases as needed, following the same pattern]
---
## Phase N: Polish & Cross-Cutting Concerns
**Purpose**: Improvements that affect multiple user stories
- [ ] TXXX [P] Documentation updates in docs/
- [ ] TXXX Code cleanup and refactoring
- [ ] TXXX Performance optimization across all stories
- [ ] TXXX [P] Additional unit tests (if requested) in tests/unit/
- [ ] TXXX Security hardening
- [ ] TXXX Run quickstart.md validation
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies - can start immediately
- **Foundational (Phase 2)**: Depends on Setup completion - BLOCKS all user stories
- **User Stories (Phase 3+)**: All depend on Foundational phase completion
- User stories can then proceed in parallel (if staffed)
- Or sequentially in priority order (P1 → P2 → P3)
- **Polish (Final Phase)**: Depends on all desired user stories being complete
### User Story Dependencies
- **User Story 1 (P1)**: Can start after Foundational (Phase 2) - No dependencies on other stories
- **User Story 2 (P2)**: Can start after Foundational (Phase 2) - May integrate with US1 but should be independently testable
- **User Story 3 (P3)**: Can start after Foundational (Phase 2) - May integrate with US1/US2 but should be independently testable
### Within Each User Story
- Tests (if included) MUST be written and FAIL before implementation
- Models before services
- Services before endpoints
- Core implementation before integration
- Story complete before moving to next priority
### Parallel Opportunities
- All Setup tasks marked [P] can run in parallel
- All Foundational tasks marked [P] can run in parallel (within Phase 2)
- Once Foundational phase completes, all user stories can start in parallel (if team capacity allows)
- All tests for a user story marked [P] can run in parallel
- Models within a story marked [P] can run in parallel
- Different user stories can be worked on in parallel by different team members
---
## Parallel Example: User Story 1
```bash
# Launch all tests for User Story 1 together (if tests requested):
Task: "Contract test for [endpoint] in tests/contract/test_[name].py"
Task: "Integration test for [user journey] in tests/integration/test_[name].py"
# Launch all models for User Story 1 together:
Task: "Create [Entity1] model in src/models/[entity1].py"
Task: "Create [Entity2] model in src/models/[entity2].py"
```
---
## Implementation Strategy
### MVP First (User Story 1 Only)
1. Complete Phase 1: Setup
2. Complete Phase 2: Foundational (CRITICAL - blocks all stories)
3. Complete Phase 3: User Story 1
4. **STOP and VALIDATE**: Test User Story 1 independently
5. Deploy/demo if ready
### Incremental Delivery
1. Complete Setup + Foundational → Foundation ready
2. Add User Story 1 → Test independently → Deploy/Demo (MVP!)
3. Add User Story 2 → Test independently → Deploy/Demo
4. Add User Story 3 → Test independently → Deploy/Demo
5. Each story adds value without breaking previous stories
### Parallel Team Strategy
With multiple developers:
1. Team completes Setup + Foundational together
2. Once Foundational is done:
- Developer A: User Story 1
- Developer B: User Story 2
- Developer C: User Story 3
3. Stories complete and integrate independently
---
## Notes
- [P] tasks = different files, no dependencies
- [Story] label maps task to specific user story for traceability
- Each user story should be independently completable and testable
- Verify tests fail before implementing
- Commit after each task or logical group
- Stop at any checkpoint to validate story independently
- Avoid: vague tasks, same file conflicts, cross-story dependencies that break independence

View File

@@ -1,4 +1,4 @@
# CLAUDE.md # CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

View File

@@ -9,6 +9,7 @@ Upon first run it will generate thumbnails for all images and videos at `BASE_PA
- Video streaming with HLS - Video streaming with HLS
- Tag-based organization - Tag-based organization
- Memories API for browsing photos by date - Memories API for browsing photos by date
- **Video Wall** - Auto-generated short preview clips for videos, served via a grid view
- **AI-Powered Photo Insights** - Generate contextual insights from photos using LLMs - **AI-Powered Photo Insights** - Generate contextual insights from photos using LLMs
- **RAG-based Context Retrieval** - Semantic search over daily conversation summaries - **RAG-based Context Retrieval** - Semantic search over daily conversation summaries
- **Automatic Daily Summaries** - LLM-generated summaries of daily conversations with embeddings - **Automatic Daily Summaries** - LLM-generated summaries of daily conversations with embeddings
@@ -22,10 +23,12 @@ You must have `ffmpeg` installed for streaming video and generating video thumbn
- `BASE_PATH` is the root from which you want to serve images and videos - `BASE_PATH` is the root from which you want to serve images and videos
- `THUMBNAILS` is a path where generated thumbnails should be stored - `THUMBNAILS` is a path where generated thumbnails should be stored
- `VIDEO_PATH` is a path where HLS playlists and video parts should be stored - `VIDEO_PATH` is a path where HLS playlists and video parts should be stored
- `GIFS_DIRECTORY` is a path where generated video GIF thumbnails should be stored
- `BIND_URL` is the url and port to bind to (typically your own IP address) - `BIND_URL` is the url and port to bind to (typically your own IP address)
- `SECRET_KEY` is the *hopefully* random string to sign Tokens with - `SECRET_KEY` is the *hopefully* random string to sign Tokens with
- `RUST_LOG` is one of `off, error, warn, info, debug, trace`, from least to most noisy [error is default] - `RUST_LOG` is one of `off, error, warn, info, debug, trace`, from least to most noisy [error is default]
- `EXCLUDED_DIRS` is a comma separated list of directories to exclude from the Memories API - `EXCLUDED_DIRS` is a comma separated list of directories to exclude from the Memories API
- `PREVIEW_CLIPS_DIRECTORY` (optional) is a path where generated video preview clips should be stored [default: `preview_clips`]
- `WATCH_QUICK_INTERVAL_SECONDS` (optional) is the interval in seconds for quick file scans [default: 60] - `WATCH_QUICK_INTERVAL_SECONDS` (optional) is the interval in seconds for quick file scans [default: 60]
- `WATCH_FULL_INTERVAL_SECONDS` (optional) is the interval in seconds for full file scans [default: 3600] - `WATCH_FULL_INTERVAL_SECONDS` (optional) is the interval in seconds for full file scans [default: 3600]

View File

@@ -0,0 +1 @@
DROP TABLE IF EXISTS video_preview_clips;

View File

@@ -0,0 +1,13 @@
CREATE TABLE video_preview_clips (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
file_path TEXT NOT NULL UNIQUE,
status TEXT NOT NULL DEFAULT 'pending',
duration_seconds REAL,
file_size_bytes INTEGER,
error_message TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX idx_preview_clips_file_path ON video_preview_clips(file_path);
CREATE INDEX idx_preview_clips_status ON video_preview_clips(status);

View File

@@ -0,0 +1,36 @@
# Specification Quality Checklist: VideoWall
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-02-25
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- All items pass validation.
- Assumptions section documents reasonable defaults for format choice, column layout interpretation, and infrastructure reuse.
- No [NEEDS CLARIFICATION] markers were needed — the user description was specific enough to make informed decisions for all requirements.

View File

@@ -0,0 +1,91 @@
# API Contracts: VideoWall
## GET /video/preview
Retrieve the preview clip MP4 file for a given video. If the preview is not yet generated, triggers on-demand generation and returns 202.
**Authentication**: Required (Bearer token)
**Query Parameters**:
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| path | string | yes | Relative path of the source video from BASE_PATH |
**Responses**:
| Status | Content-Type | Body | Description |
|--------|-------------|------|-------------|
| 200 | video/mp4 | MP4 file stream | Preview clip is ready and served |
| 202 | application/json | `{"status": "processing", "path": "<path>"}` | Preview generation has been triggered; client should retry |
| 400 | application/json | `{"error": "Invalid path"}` | Path validation failed |
| 404 | application/json | `{"error": "Video not found"}` | Source video does not exist |
| 500 | application/json | `{"error": "Generation failed: <detail>"}` | Preview generation failed |
**Behavior**:
1. Validate path with `is_valid_full_path()`
2. Check if preview clip exists on disk and status is `complete` → serve MP4 (200)
3. If status is `pending` or no record exists → trigger generation, return 202
4. If status is `processing` → return 202
5. If status is `failed` → return 500 with error detail
---
## POST /video/preview/status
Check the preview generation status for a batch of video paths. Used by the mobile app to determine which previews are ready before requesting them.
**Authentication**: Required (Bearer token)
**Request Body** (application/json):
```json
{
"paths": [
"2024/vacation/beach.mov",
"2024/vacation/sunset.mp4",
"2024/birthday.avi"
]
}
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| paths | string[] | yes | Array of relative video paths from BASE_PATH |
**Response** (200, application/json):
```json
{
"previews": [
{
"path": "2024/vacation/beach.mov",
"status": "complete",
"preview_url": "/video/preview?path=2024/vacation/beach.mov"
},
{
"path": "2024/vacation/sunset.mp4",
"status": "processing",
"preview_url": null
},
{
"path": "2024/birthday.avi",
"status": "pending",
"preview_url": null
}
]
}
```
| Field | Type | Description |
|-------|------|-------------|
| previews | object[] | Status for each requested path |
| previews[].path | string | The requested video path |
| previews[].status | string | One of: `pending`, `processing`, `complete`, `failed`, `not_found` |
| previews[].preview_url | string? | Relative URL to fetch the preview (only when status is `complete`) |
**Behavior**:
1. Accept up to 200 paths per request
2. Batch query the `video_preview_clips` table for all paths
3. For paths not in the table, return status `not_found` (video may not exist or hasn't been scanned yet)
4. Return results in the same order as the input paths

View File

@@ -0,0 +1,62 @@
# Data Model: VideoWall
## Entities
### VideoPreviewClip
Tracks the generation status and metadata of preview clips derived from source videos.
**Table**: `video_preview_clips`
| Field | Type | Constraints | Description |
|-------|------|-------------|-------------|
| id | INTEGER | PRIMARY KEY, AUTOINCREMENT | Unique identifier |
| file_path | TEXT | NOT NULL, UNIQUE | Relative path of the source video from BASE_PATH |
| status | TEXT | NOT NULL, DEFAULT 'pending' | Generation status: `pending`, `processing`, `complete`, `failed` |
| duration_seconds | REAL | NULLABLE | Duration of the generated preview clip (≤10s) |
| file_size_bytes | INTEGER | NULLABLE | Size of the generated MP4 file |
| error_message | TEXT | NULLABLE | Error details if status is `failed` |
| created_at | TEXT | NOT NULL | ISO 8601 timestamp when record was created |
| updated_at | TEXT | NOT NULL | ISO 8601 timestamp when record was last updated |
**Indexes**:
- `idx_preview_clips_file_path` on `file_path` (unique, used for lookups and batch queries)
- `idx_preview_clips_status` on `status` (used by file watcher to find pending/failed clips)
### Relationships
- **VideoPreviewClip → Source Video**: One-to-one via `file_path`. The preview clip file on disk is located at `{PREVIEW_CLIPS_DIRECTORY}/{file_path}.mp4`.
- **VideoPreviewClip → image_exif**: Implicit relationship via shared `file_path`. No foreign key needed — the EXIF table may not have an entry for every video.
## State Transitions
```
[new video detected] → pending
pending → processing (when generation starts)
processing → complete (when ffmpeg succeeds)
processing → failed (when ffmpeg fails or times out)
failed → pending (on retry / re-scan)
```
## Validation Rules
- `file_path` must be a valid relative path within BASE_PATH
- `status` must be one of: `pending`, `processing`, `complete`, `failed`
- `duration_seconds` must be > 0 and ≤ 10.0 when status is `complete`
- `file_size_bytes` must be > 0 when status is `complete`
- `error_message` should only be non-null when status is `failed`
## Storage Layout (Filesystem)
```
{PREVIEW_CLIPS_DIRECTORY}/
├── 2024/
│ ├── vacation/
│ │ ├── beach.mp4 # Preview for BASE_PATH/2024/vacation/beach.mov
│ │ └── sunset.mp4 # Preview for BASE_PATH/2024/vacation/sunset.mp4
│ └── birthday.mp4 # Preview for BASE_PATH/2024/birthday.avi
└── 2025/
└── trip.mp4 # Preview for BASE_PATH/2025/trip.mkv
```
All preview clips use `.mp4` extension regardless of source format.

View File

@@ -0,0 +1,79 @@
# Implementation Plan: VideoWall
**Branch**: `001-video-wall` | **Date**: 2026-02-25 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `/specs/001-video-wall/spec.md`
## Summary
Add a VideoWall feature spanning the Rust API backend and React Native mobile app. The backend generates 480p MP4 preview clips (up to 10 seconds, composed of 10 equally spaced 1-second segments) using ffmpeg, extending the existing `OverviewVideo` pattern in `src/video/ffmpeg.rs`. The mobile app adds a VideoWall view using `expo-video` and FlatList to display a responsive 2-3 column grid of simultaneously looping, muted preview clips with audio-on-long-press. Preview clips are cached on disk, served via new API endpoints, and generated proactively by the file watcher.
## Technical Context
**Language/Version**: Rust (stable, Cargo) for backend API; TypeScript / React Native (Expo SDK 52) for mobile app
**Primary Dependencies**: actix-web 4, Diesel 2.2 (SQLite), ffmpeg/ffprobe (CLI), expo-video 3.0, expo-router 6.0, react-native-reanimated 4.1
**Storage**: SQLite (preview clip status tracking), filesystem (MP4 preview clips in `PREVIEW_CLIPS_DIRECTORY`)
**Testing**: `cargo test` for backend; manual testing for mobile app
**Target Platform**: Linux server (API), iOS/Android (mobile app via Expo)
**Project Type**: Mobile app + REST API (two separate repositories)
**Performance Goals**: <3s VideoWall load for 50 pre-generated previews; <30s per clip generation; <5MB per clip; smooth simultaneous playback of 6-12 clips
**Constraints**: Semaphore-limited concurrent ffmpeg processes (existing pattern); 480p resolution to keep bandwidth/CPU manageable; audio track preserved but muted by default
**Scale/Scope**: Hundreds to low thousands of videos per library; single user at a time
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
Constitution is an unfilled template — no project-specific gates defined. **PASS** (no violations possible).
Post-Phase 1 re-check: Still PASS — no gates to evaluate.
## Project Structure
### Documentation (this feature)
```text
specs/001-video-wall/
├── plan.md # This file
├── research.md # Phase 0 output
├── data-model.md # Phase 1 output
├── quickstart.md # Phase 1 output
├── contracts/ # Phase 1 output
│ └── api-endpoints.md
└── tasks.md # Phase 2 output (/speckit.tasks command)
```
### Source Code (repository root)
```text
# Backend (ImageApi - Rust)
src/
├── video/
│ ├── ffmpeg.rs # Add generate_preview_clip() using existing pattern
│ ├── actors.rs # Add PreviewClipGenerator actor (semaphore-limited)
│ └── mod.rs # Add generate_preview_clips() batch function
├── main.rs # Add GET /video/preview, POST /video/preview/status endpoints
│ # Extend file watcher to trigger preview generation
├── database/
│ ├── schema.rs # Add video_preview_clips table
│ └── models.rs # Add VideoPreviewClip model
│ └── preview_dao.rs # New DAO for preview clip status tracking
└── data/
└── mod.rs # Add PreviewClipRequest, PreviewStatusRequest types
# Frontend (SynologyFileViewer - React Native)
app/(app)/grid/
├── video-wall.tsx # New VideoWall view (FlatList grid)
└── _layout.tsx # Add video-wall route to stack
components/
└── VideoWallItem.tsx # Single preview clip cell (expo-video player)
hooks/
└── useVideoWall.ts # Preview clip fetching, status polling, audio state
```
**Structure Decision**: Mobile + API pattern. Backend changes extend existing `src/video/` module and `src/main.rs` handlers following established conventions. Frontend adds a new route under the existing grid stack navigator with a dedicated component and hook.
## Complexity Tracking
No constitution violations to justify.

View File

@@ -0,0 +1,115 @@
# Quickstart: VideoWall
## Prerequisites
- Rust toolchain (stable) with `cargo`
- `diesel_cli` installed (`cargo install diesel_cli --no-default-features --features sqlite`)
- ffmpeg and ffprobe available on PATH
- Node.js 18+ and Expo CLI for mobile app
- `.env` file configured with existing variables plus `PREVIEW_CLIPS_DIRECTORY`
## New Environment Variable
Add to `.env`:
```bash
PREVIEW_CLIPS_DIRECTORY=/path/to/preview-clips # Directory for generated preview MP4s
```
## Backend Development
### 1. Create database migration
```bash
cd C:\Users\ccord\RustroverProjects\ImageApi
diesel migration generate create_video_preview_clips
```
Edit the generated `up.sql`:
```sql
CREATE TABLE video_preview_clips (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
file_path TEXT NOT NULL UNIQUE,
status TEXT NOT NULL DEFAULT 'pending',
duration_seconds REAL,
file_size_bytes INTEGER,
error_message TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX idx_preview_clips_file_path ON video_preview_clips(file_path);
CREATE INDEX idx_preview_clips_status ON video_preview_clips(status);
```
Edit `down.sql`:
```sql
DROP TABLE IF EXISTS video_preview_clips;
```
Regenerate schema:
```bash
diesel migration run
diesel print-schema > src/database/schema.rs
```
### 2. Build and test backend
```bash
cargo build
cargo test
cargo run
```
Test preview endpoint:
```bash
# Check preview status
curl -X POST http://localhost:8080/video/preview/status \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"paths": ["some/video.mp4"]}'
# Request preview clip
curl http://localhost:8080/video/preview?path=some/video.mp4 \
-H "Authorization: Bearer <token>" \
-o preview.mp4
```
### 3. Verify preview clip generation
Check that preview clips appear in `PREVIEW_CLIPS_DIRECTORY` with the expected directory structure mirroring `BASE_PATH`.
## Frontend Development
### 1. Start the mobile app
```bash
cd C:\Users\ccord\development\SynologyFileViewer
npx expo start
```
### 2. Navigate to VideoWall
From the grid view of any folder containing videos, switch to VideoWall mode. The view should display a 2-3 column grid of looping preview clips.
## Key Files to Modify
### Backend (ImageApi)
| File | Change |
|------|--------|
| `src/video/ffmpeg.rs` | Add `generate_preview_clip()` function |
| `src/video/actors.rs` | Add `PreviewClipGenerator` actor |
| `src/video/mod.rs` | Add `generate_preview_clips()` batch function |
| `src/main.rs` | Add endpoints, extend file watcher |
| `src/database/schema.rs` | Regenerated by Diesel |
| `src/database/models.rs` | Add `VideoPreviewClip` struct |
| `src/database/preview_dao.rs` | New DAO file |
| `src/data/mod.rs` | Add request/response types |
| `src/state.rs` | Add PreviewClipGenerator to AppState |
### Frontend (SynologyFileViewer)
| File | Change |
|------|--------|
| `app/(app)/grid/video-wall.tsx` | New VideoWall view |
| `app/(app)/grid/_layout.tsx` | Add route |
| `components/VideoWallItem.tsx` | New preview clip cell component |
| `hooks/useVideoWall.ts` | New hook for preview state management |

View File

@@ -0,0 +1,91 @@
# Research: VideoWall
## R1: FFmpeg Preview Clip Generation Strategy
**Decision**: Use ffmpeg's `select` filter with segment-based extraction, extending the existing `OverviewVideo` pattern in `src/video/ffmpeg.rs`.
**Rationale**: The codebase already has a nearly identical pattern at `src/video/ffmpeg.rs` using `select='lt(mod(t,{interval}),1)'` which selects 1-second frames at evenly spaced intervals across the video duration. The existing pattern outputs GIF; we adapt it to output MP4 at 480p with audio.
**Approach**:
1. Use `ffprobe` to get video duration (existing `get_video_duration()` pattern)
2. Calculate interval: `duration / 10` (or fewer segments for short videos)
3. Use ffmpeg with:
- Video filter: `select='lt(mod(t,{interval}),1)',setpts=N/FRAME_RATE/TB,scale=-2:480`
- Audio filter: `aselect='lt(mod(t,{interval}),1)',asetpts=N/SR/TB`
- Output: MP4 with H.264 video + AAC audio
- CRF 28 (lower quality acceptable for previews, reduces file size)
- Preset: `veryfast` (matches existing HLS transcoding pattern)
**Alternatives considered**:
- Generating separate segment files and concatenating: More complex, no benefit over select filter
- Using GIF output: Rejected per clarification — MP4 is 5-10x smaller with better quality
- Stream copy (no transcode): Not possible since we're extracting non-contiguous segments
## R2: Preview Clip Storage and Caching
**Decision**: Store preview clips on filesystem in a dedicated `PREVIEW_CLIPS_DIRECTORY` mirroring the source directory structure (same pattern as `THUMBNAILS` and `GIFS_DIRECTORY`).
**Rationale**: The project already uses this directory-mirroring pattern for thumbnails and GIF previews. It's simple, requires no database for file lookup (path is deterministic), and integrates naturally with the existing file watcher cleanup logic.
**Storage path formula**: `{PREVIEW_CLIPS_DIRECTORY}/{relative_path_from_BASE_PATH}.mp4`
- Example: Video at `BASE_PATH/2024/vacation.mov` → Preview at `PREVIEW_CLIPS_DIRECTORY/2024/vacation.mp4`
**Alternatives considered**:
- Database BLOBs: Too large, not suited for binary video files
- Content-addressed storage (hash-based): Unnecessary complexity for single-user system
- Flat directory with UUID names: Loses the intuitive mapping that thumbnails/GIFs use
## R3: Preview Generation Status Tracking
**Decision**: Track generation status in SQLite via a new `video_preview_clips` table with Diesel ORM, following the existing DAO pattern.
**Rationale**: The batch status endpoint (FR-004) needs to efficiently check which previews are ready for a list of video paths. A database table is the right tool — it supports batch queries (existing `get_exif_batch()` pattern), survives restarts, and tracks failure states. The file watcher already uses batch DB queries to detect unprocessed files.
**Status values**: `pending`, `processing`, `complete`, `failed`
**Alternatives considered**:
- Filesystem-only (check if .mp4 exists): Cannot track `processing` or `failed` states; race conditions on concurrent requests
- In-memory HashMap: Lost on restart; doesn't support batch queries efficiently across actor boundaries
## R4: Concurrent Generation Limits
**Decision**: Use `Arc<Semaphore>` with a limit of 2 concurrent ffmpeg preview generation processes, matching the existing `PlaylistGenerator` pattern.
**Rationale**: The `PlaylistGenerator` actor in `src/video/actors.rs` already uses this exact pattern to limit concurrent ffmpeg processes. Preview generation is CPU-intensive (transcoding), so limiting concurrency prevents server overload. The semaphore pattern is proven in this codebase.
**Alternatives considered**:
- Unbounded concurrency: Would overwhelm the server with many simultaneous ffmpeg processes
- Queue with single worker: Too slow for batch generation; 2 concurrent is a good balance
- Sharing the existing PlaylistGenerator semaphore: Would cause HLS generation and preview generation to compete for the same slots; better to keep them independent
## R5: Mobile App Video Playback Strategy
**Decision**: Use `expo-video` `VideoView` components inside FlatList items, with muted autoplay and viewport-based pause/resume.
**Rationale**: The app already uses `expo-video` (v3.0.15) for the single video player in `viewer/video.tsx`. The library supports multiple simultaneous players, `loop` mode, and programmatic mute/unmute. FlatList's `viewabilityConfig` callback can be used to pause/resume players based on viewport visibility.
**Key configuration per cell**:
- `player.loop = true`
- `player.muted = true` (default)
- `player.play()` when visible, `player.pause()` when offscreen
- `nativeControls={false}` (no controls needed in grid)
**Audio-on-focus**: On long-press, unmute the pressed player and mute all others. Track the "focused" player ID in hook state.
**Alternatives considered**:
- HLS streaming for previews: Overkill for <10s clips; direct MP4 download is simpler and faster
- Animated GIF display via Image component: Rejected per clarification — MP4 with expo-video is better
- WebView-based player: Poor performance, no native gesture integration
## R6: API Endpoint Design
**Decision**: Two new endpoints — one to serve preview clips, one for batch status checking.
**Rationale**:
- `GET /video/preview?path=...` serves the MP4 file directly (or triggers on-demand generation and returns 202 Accepted). Follows the pattern of `GET /image?path=...` for serving files.
- `POST /video/preview/status` accepts a JSON body with an array of video paths and returns their preview generation status. This allows the mobile app to efficiently determine which previews are ready in a single request (batch pattern from `get_exif_batch()`).
**Alternatives considered**:
- Single endpoint that blocks until generation completes: Bad UX — generation takes up to 30s
- WebSocket for real-time status: Overkill for this use case; polling with batch status is simpler
- Including preview URL in the existing `/photos` response: Would couple the photo listing endpoint to preview generation; better to keep separate

View File

@@ -0,0 +1,136 @@
# Feature Specification: VideoWall
**Feature Branch**: `001-video-wall`
**Created**: 2026-02-25
**Status**: Draft
**Input**: User description: "I would like to implement a new View 'VideoWall' in the React native mobile app, with supporting API/tasks to generate at most 10 second long GIF/Videos that are 10 equally spaced 1 second clips of the original video. This view will display a grid 2/3 columns wide of all these clips playing simultaneously. It should let the user view all videos in the current folder/search results."
## Clarifications
### Session 2026-02-25
- Q: What format should preview clips be generated in (GIF vs video)? → A: MP4 video clips (small files, hardware-accelerated playback, best quality-to-size ratio).
- Q: What resolution should preview clips be generated at? → A: 480p scaled down (sharp in grid cells, small files, smooth simultaneous playback).
- Q: How should audio be handled in preview clips? → A: Audio on focus — muted by default, audio plays when user long-presses on a clip. Audio track is preserved during generation.
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Browse Videos as a Visual Wall (Priority: P1)
A user navigates to a folder containing videos and switches to the VideoWall view. The screen fills with a grid of video previews — short looping clips that give a visual summary of each video. All previews play simultaneously, creating an immersive "wall of motion" that lets the user quickly scan and identify videos of interest without opening each one individually.
**Why this priority**: This is the core experience. Without the visual grid of simultaneously playing previews, the feature has no value. This story delivers the primary browsing capability.
**Independent Test**: Can be fully tested by navigating to any folder with videos, switching to VideoWall view, and confirming that preview clips display in a grid and play simultaneously. Delivers immediate visual browsing value.
**Acceptance Scenarios**:
1. **Given** a user is viewing a folder containing 6 videos, **When** they switch to VideoWall view, **Then** they see a grid of 6 video previews arranged in 2-3 columns, all playing simultaneously in a loop.
2. **Given** a user is viewing a folder containing 20 videos, **When** they switch to VideoWall view, **Then** the grid is scrollable and loads previews progressively as they scroll.
3. **Given** a user is in VideoWall view, **When** they tap on a video preview, **Then** they navigate to the full video player for that video.
4. **Given** a user is in VideoWall view with all clips muted, **When** they long-press on a preview clip, **Then** that clip's audio unmutes and all other clips remain muted.
---
### User Story 2 - Server Generates Preview Clips (Priority: P1)
When preview clips are requested for a video that has not yet been processed, the server generates a short preview clip. The preview is composed of 10 equally spaced 1-second segments extracted from the original video, concatenated into a single clip of at most 10 seconds. Once generated, the preview is cached so subsequent requests are served instantly.
**Why this priority**: The VideoWall view depends entirely on having preview clips available. Without server-side generation, there is nothing to display. This is co-priority with Story 1 as they are interdependent.
**Independent Test**: Can be tested by requesting a preview clip for any video via the API and confirming the response is a playable clip of at most 10 seconds composed of segments from different parts of the original video.
**Acceptance Scenarios**:
1. **Given** a video exists that has no preview clip yet, **When** a preview is requested, **Then** the system generates a clip of at most 10 seconds composed of 10 equally spaced 1-second segments from the original video.
2. **Given** a video is shorter than 10 seconds, **When** a preview is requested, **Then** the system generates a preview using fewer segments (as many 1-second clips as the video duration allows), resulting in a shorter preview.
3. **Given** a preview clip was previously generated for a video, **When** it is requested again, **Then** the cached version is served without re-processing.
4. **Given** a video file no longer exists, **When** a preview is requested, **Then** the system returns an appropriate error indicating the source video is missing.
---
### User Story 3 - VideoWall from Search Results (Priority: P2)
A user performs a search or applies filters (tags, date range, camera, location) and the results include videos. They switch to VideoWall view to see preview clips of all matching videos displayed in the same grid layout, allowing visual browsing of search results.
**Why this priority**: Extends the core VideoWall browsing to work with filtered/search result sets. Important for discoverability but depends on Story 1 and 2 being functional first.
**Independent Test**: Can be tested by performing a search that returns videos, switching to VideoWall view, and confirming that only matching videos appear as previews in the grid.
**Acceptance Scenarios**:
1. **Given** a user has search results containing 8 videos and 12 photos, **When** they switch to VideoWall view, **Then** only the 8 video previews are displayed in the grid.
2. **Given** a user applies a tag filter that matches 3 videos, **When** they view the VideoWall, **Then** exactly 3 video previews are shown.
---
### User Story 4 - Background Preview Generation (Priority: P3)
Preview clips are generated proactively in the background for videos discovered during file watching, so that when a user opens VideoWall, most previews are already available and the experience feels instant.
**Why this priority**: Enhances performance and perceived responsiveness. The feature works without this (on-demand generation), but background processing greatly improves the user experience for large libraries.
**Independent Test**: Can be tested by adding new video files to a monitored folder and confirming that preview clips are generated automatically within the next scan cycle, before any user requests them.
**Acceptance Scenarios**:
1. **Given** a new video is added to the media library, **When** the file watcher detects it, **Then** a preview clip is generated in the background without user intervention.
2. **Given** the system is generating previews in the background, **When** a user opens VideoWall, **Then** already-generated previews display immediately while pending ones show a placeholder.
---
### Edge Cases
- What happens when a video is corrupted or cannot be processed? The system shows a placeholder/error state for that video and does not block other previews from loading.
- What happens when the user scrolls quickly through a large library? Previews outside the visible viewport should pause or not load to conserve resources, and resume when scrolled back into view.
- What happens when a video is extremely long (e.g., 4+ hours)? The same algorithm applies — 10 equally spaced 1-second clips — ensuring the preview still represents the full video.
- What happens when a video is exactly 10 seconds long? Each 1-second segment starts at second 0, 1, 2, ... 9, effectively previewing the entire video.
- What happens when storage for preview clips runs low? Preview clips should be reasonably compressed and sized to minimize storage impact.
- What happens when many previews are requested simultaneously (e.g., opening a folder with 100 videos)? The system should queue generation and serve already-cached previews immediately while others are processed.
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: System MUST generate preview clips for videos as MP4 files scaled to 480p resolution, where each preview is composed of up to 10 equally spaced 1-second segments from the original video, resulting in a clip of at most 10 seconds.
- **FR-002**: System MUST cache generated preview clips so they are only generated once per source video.
- **FR-003**: System MUST provide an endpoint to retrieve a preview clip for a given video path.
- **FR-004**: System MUST provide an endpoint to retrieve preview availability status for a batch of video paths so the client knows which previews are ready.
- **FR-005**: The mobile app MUST display a VideoWall view showing video previews in a grid of 2 columns on smaller screens and 3 columns on larger screens.
- **FR-006**: All visible preview clips in the VideoWall MUST play simultaneously, muted, and loop continuously.
- **FR-006a**: When a user long-presses on a preview clip, the app MUST unmute that clip's audio. Only one clip may have audio at a time.
- **FR-006b**: Preview clips MUST retain their audio track during generation (not stripped) to support audio-on-focus playback.
- **FR-007**: The VideoWall MUST support browsing videos from both folder navigation and search/filter results.
- **FR-008**: Tapping a preview clip in the VideoWall MUST navigate the user to the full video.
- **FR-009**: For videos shorter than 10 seconds, the system MUST generate a preview using as many full 1-second segments as the video duration allows.
- **FR-010**: The system MUST display a placeholder for videos whose preview clips are not yet generated.
- **FR-011**: The system MUST handle unprocessable videos gracefully by showing an error state rather than failing the entire wall.
- **FR-012**: The VideoWall MUST support scrolling through large numbers of videos, loading previews progressively.
- **FR-013**: Preview clips outside the visible viewport SHOULD pause playback to conserve device resources.
### Key Entities
- **Video Preview Clip**: A short looping MP4 video (at most 10 seconds) scaled to 480p resolution, derived from a source video. Composed of up to 10 equally spaced 1-second segments. Associated with exactly one source video by file path. Has a generation status (pending, processing, complete, failed).
- **VideoWall View**: A scrollable grid layout displaying video preview clips. Adapts column count based on screen size (2 or 3 columns). Operates on a set of videos from a folder or search result context.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: Users can visually browse all videos in a folder within 3 seconds of opening VideoWall (for folders with up to 50 videos with pre-generated previews).
- **SC-002**: Preview clips accurately represent the source video by sampling from evenly distributed points across the full duration.
- **SC-003**: All visible previews play simultaneously without noticeable stuttering on standard mobile devices.
- **SC-004**: Generated preview clips are each under 5 MB in size to keep storage and bandwidth manageable.
- **SC-005**: The VideoWall view correctly filters to show only videos (not photos) from the current folder or search results.
- **SC-006**: Users can identify and select a video of interest from the VideoWall and navigate to it in a single tap.
- **SC-007**: Preview generation for a single video completes within 30 seconds on typical hardware.
## Assumptions
- The existing file watcher and thumbnail generation infrastructure will be extended to also trigger preview clip generation.
- Preview clips will be stored alongside existing thumbnails/GIFs in a designated directory on the server.
- The React Native mobile app already has folder navigation and search/filter capabilities that provide the video list context for VideoWall.
- The server already has ffmpeg available for video processing (used for existing HLS and GIF generation).
- Authentication and authorization follow the existing JWT-based pattern; no new auth requirements.
- "2/3 columns" means a responsive layout: 2 columns on phones (portrait), 3 columns on tablets or landscape orientation.
- Preview clips are generated as MP4 video files for optimal quality-to-size ratio and hardware-accelerated mobile playback.

View File

@@ -0,0 +1,234 @@
# Tasks: VideoWall
**Input**: Design documents from `/specs/001-video-wall/`
**Prerequisites**: plan.md (required), spec.md (required), research.md, data-model.md, contracts/
**Tests**: Not explicitly requested — test tasks omitted.
**Organization**: Tasks grouped by user story. US2 (server generation) comes before US1 (mobile view) because the mobile app depends on the API endpoints existing.
## Format: `[ID] [P?] [Story] Description`
- **[P]**: Can run in parallel (different files, no dependencies)
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
- Include exact file paths in descriptions
## Path Conventions
- **Backend (ImageApi)**: `src/` at `C:\Users\ccord\RustroverProjects\ImageApi`
- **Frontend (SynologyFileViewer)**: `app/`, `components/`, `hooks/` at `C:\Users\ccord\development\SynologyFileViewer`
---
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Database migration, new environment variable, shared types
- [x] T001 Create Diesel migration for `video_preview_clips` table: run `diesel migration generate create_video_preview_clips`, write `up.sql` with table definition (id, file_path UNIQUE, status DEFAULT 'pending', duration_seconds, file_size_bytes, error_message, created_at, updated_at) and indexes (idx_preview_clips_file_path, idx_preview_clips_status), write `down.sql` with DROP TABLE. See `data-model.md` for full schema.
- [x] T002 Run migration and regenerate schema: execute `diesel migration run` then `diesel print-schema > src/database/schema.rs` to add the `video_preview_clips` table to `src/database/schema.rs`
- [x] T003 Add `PREVIEW_CLIPS_DIRECTORY` environment variable: read it in `src/main.rs` startup (alongside existing `GIFS_DIRECTORY`), create the directory if it doesn't exist, and add it to `AppState` or pass it where needed. Follow the pattern used for `GIFS_DIRECTORY` and `THUMBNAILS`.
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Diesel model, DAO, and request/response types that all user stories depend on
**CRITICAL**: No user story work can begin until this phase is complete
- [x] T004 [P] Add `VideoPreviewClip` Diesel model struct in `src/database/models.rs` with fields matching the `video_preview_clips` schema table (Queryable, Insertable derives). Add a `NewVideoPreviewClip` struct for inserts.
- [x] T005 [P] Add `PreviewClipRequest` and `PreviewStatusRequest`/`PreviewStatusResponse` types in `src/data/mod.rs`. `PreviewClipRequest` has `path: String`. `PreviewStatusRequest` has `paths: Vec<String>`. `PreviewStatusResponse` has `previews: Vec<PreviewStatusItem>` where each item has `path`, `status`, `preview_url: Option<String>`. All with Serialize/Deserialize derives.
- [x] T006 Create `PreviewDao` trait and `SqlitePreviewDao` implementation in `src/database/preview_dao.rs`. Methods: `insert_preview(file_path, status) -> Result`, `update_status(file_path, status, duration_seconds?, file_size_bytes?, error_message?) -> Result`, `get_preview(file_path) -> Result<Option<VideoPreviewClip>>`, `get_previews_batch(file_paths: &[String]) -> Result<Vec<VideoPreviewClip>>`, `get_by_status(status) -> Result<Vec<VideoPreviewClip>>`. Follow the `ExifDao`/`SqliteExifDao` pattern with `Arc<Mutex<SqliteConnection>>` and OpenTelemetry tracing spans.
- [x] T007 Register `preview_dao` module in `src/database/mod.rs` and add `PreviewDao` to the database module exports. Wire `SqlitePreviewDao` into `AppState` in `src/state.rs` following the existing DAO pattern (e.g., how `ExifDao` is added).
**Checkpoint**: Foundation ready — DAO, models, and types available for all stories
---
## Phase 3: User Story 2 - Server Generates Preview Clips (Priority: P1) MVP
**Goal**: Backend can generate 480p MP4 preview clips (10 equally spaced 1-second segments) and serve them via API endpoints with on-demand generation and batch status checking.
**Independent Test**: Request `GET /video/preview?path=<video>` for any video — should return an MP4 file of at most 10 seconds. Request `POST /video/preview/status` with video paths — should return status for each.
### Implementation for User Story 2
- [x] T008 [P] [US2] Add `generate_preview_clip()` function in `src/video/ffmpeg.rs`. Takes input video path, output MP4 path, and video duration. Uses ffprobe to get duration (existing pattern). Calculates interval = `duration / 10` (or fewer for short videos per FR-009). Builds ffmpeg command with: video filter `select='lt(mod(t,{interval}),1)',setpts=N/FRAME_RATE/TB,scale=-2:480`, audio filter `aselect='lt(mod(t,{interval}),1)',asetpts=N/SR/TB`, codec H.264 CRF 28 preset veryfast, AAC audio. Output path uses `.mp4` extension. Creates parent directories for output. Returns `Result<(f64, u64)>` with (duration_seconds, file_size_bytes). See `research.md` R1 for full ffmpeg strategy.
- [x] T009 [P] [US2] Create `PreviewClipGenerator` actor in `src/video/actors.rs`. Struct holds `Arc<Semaphore>` (limit 2 concurrent), preview clips directory path, base path, and `Arc<dyn PreviewDao>`. Handles `GeneratePreviewMessage { video_path: String }`: acquires semaphore permit, updates DB status to `processing`, calls `generate_preview_clip()`, updates DB to `complete` with duration/size on success or `failed` with error on failure. Follow the `PlaylistGenerator` actor pattern with `tokio::spawn` for async processing.
- [x] T010 [US2] Add `PreviewClipGenerator` actor to `AppState` in `src/state.rs`. Initialize it during server startup in `src/main.rs` with the `PREVIEW_CLIPS_DIRECTORY`, `BASE_PATH`, and preview DAO reference. Start the actor with `PreviewClipGenerator::new(...).start()`.
- [x] T011 [US2] Implement `GET /video/preview` handler in `src/main.rs`. Validate path with `is_valid_full_path()`. Check preview DAO for status: if `complete` → serve MP4 file with `NamedFile::open()` (200); if `processing` → return 202 JSON; if `pending`/not found → insert/update record as `pending`, send `GeneratePreviewMessage` to actor, return 202 JSON; if `failed` → return 500 with error. See `contracts/api-endpoints.md` for full response contract.
- [x] T012 [US2] Implement `POST /video/preview/status` handler in `src/main.rs`. Accept `PreviewStatusRequest` JSON body. Call `preview_dao.get_previews_batch()` for all paths. Map results: for each path, return status and `preview_url` (only when `complete`). Paths not in DB get status `not_found`. Limit to 200 paths per request. Return `PreviewStatusResponse` JSON.
- [x] T013 [US2] Register both new endpoints in route configuration in `src/main.rs`. Add `web::resource("/video/preview").route(web::get().to(get_video_preview))` and `web::resource("/video/preview/status").route(web::post().to(get_preview_status))`. Both require authentication (Claims extraction).
- [x] T014 [US2] Handle short videos (< 10 seconds) in `generate_preview_clip()` in `src/video/ffmpeg.rs`. When duration < 10s, calculate segment count as `floor(duration)` and interval as `duration / segment_count`. When duration < 1s, use the entire video as the preview (just transcode to 480p MP4). Add this logic to the interval calculation in T008.
**Checkpoint**: Backend fully functional — preview clips can be generated, cached, and served via API
---
## Phase 4: User Story 1 - Browse Videos as a Visual Wall (Priority: P1) MVP
**Goal**: Mobile app displays a responsive 2-3 column grid of simultaneously looping, muted video previews with long-press audio and tap-to-navigate.
**Independent Test**: Navigate to a folder with videos in the app, switch to VideoWall view, confirm grid displays with playing previews. Long-press to hear audio. Tap to open full video.
### Implementation for User Story 1
- [x] T015 [P] [US1] Create `useVideoWall` hook in `hooks/useVideoWall.ts` (SynologyFileViewer). Accepts array of `GridItem[]` (video items only, filtered from current files context). Calls `POST /video/preview/status` with video paths on mount to get availability. Returns `{ previewStatuses: Map<string, PreviewStatus>, focusedVideoPath: string | null, setFocusedVideo: (path) => void, refreshStatuses: () => void }`. Uses `authenticatedFetch()` from auth hook. Polls status every 5 seconds for any items still in `pending`/`processing` state, stops polling when all are `complete` or `failed`.
- [x] T016 [P] [US1] Create `VideoWallItem` component in `components/VideoWallItem.tsx` (SynologyFileViewer). Renders an `expo-video` `VideoView` for a single preview clip. Props: `videoPath: string, previewStatus: string, isFocused: boolean, onTap: () => void, onLongPress: () => void, isVisible: boolean`. When `previewStatus === 'complete'`: create `useVideoPlayer` with source URL `${baseUrl}/video/preview?path=${videoPath}` and auth headers, set `player.loop = true`, `player.muted = !isFocused`. When `isVisible` is true → `player.play()`, false → `player.pause()`. When status is not complete: show placeholder (thumbnail image from existing `/image?path=&size=thumb` endpoint with a loading indicator overlay). When `failed`: show error icon overlay. Aspect ratio 16:9 with `nativeControls={false}`.
- [x] T017 [US1] Create VideoWall view in `app/(app)/grid/video-wall.tsx` (SynologyFileViewer). Use `FlatList` with `numColumns` calculated as `Math.floor(dimensions.width / 180)` (targeting 2-3 columns). Get video items from `FilesContext` — filter `allItems` or `filteredItems` to only include video extensions (use same detection as existing `isVideo()` check). Pass items to `useVideoWall` hook. Use `viewabilityConfig` with `viewAreaCoveragePercentThreshold: 50` and `onViewableItemsChanged` callback to track visible items, passing `isVisible` to each `VideoWallItem`. Implement `keyExtractor` using video path. Add scroll-to-top FAB button following existing grid pattern.
- [x] T018 [US1] Add `video-wall` route to stack navigator in `app/(app)/grid/_layout.tsx` (SynologyFileViewer). Add `<Stack.Screen name="video-wall" options={{ title: "Video Wall" }} />` to the existing Stack navigator.
- [x] T019 [US1] Add navigation entry point to switch to VideoWall from the grid view. In `app/(app)/grid/[path].tsx` (SynologyFileViewer), add a header button (e.g., a grid/video icon from `@expo/vector-icons`) that calls `router.push("/grid/video-wall")`. Only show the button when the current folder contains at least one video file.
- [x] T020 [US1] Implement long-press audio-on-focus behavior. In `VideoWallItem`, wrap the VideoView in a `Pressable` with `onLongPress` calling `onLongPress` prop. In `video-wall.tsx`, when `onLongPress` fires for an item: call `setFocusedVideo(path)` if different from current, or `setFocusedVideo(null)` to toggle off. The `isFocused` prop drives `player.muted` in `VideoWallItem` — when focused, unmute; all others stay muted.
- [x] T021 [US1] Implement tap-to-navigate to full video player. In `VideoWallItem`, the `onTap` prop triggers navigation. In `video-wall.tsx`, the `onTap` handler sets the `currentIndex` in `FilesContext` to the tapped video's index and calls `router.push("/grid/viewer/video")` following the existing pattern from `[path].tsx` grid item press.
**Checkpoint**: Full VideoWall experience works for folder browsing with simultaneous playback, audio-on-focus, and tap-to-view
---
## Phase 5: User Story 3 - VideoWall from Search Results (Priority: P2)
**Goal**: VideoWall works with search/filter results, showing only matching videos.
**Independent Test**: Perform a search with filters that returns videos, switch to VideoWall, confirm only matching videos appear.
### Implementation for User Story 3
- [x] T022 [US3] Ensure VideoWall uses `filteredItems` when available. In `app/(app)/grid/video-wall.tsx` (SynologyFileViewer), check if `filteredItems` from `FilesContext` is non-empty — if so, use `filteredItems` filtered to videos only; otherwise use `allItems` filtered to videos. This should already work if T017 reads from the context correctly, but verify the logic handles both folder browsing and search result modes.
- [x] T023 [US3] Add VideoWall toggle from search results. In `app/(app)/search.tsx` (SynologyFileViewer), add a button (same icon as T019) that navigates to `/grid/video-wall` when search results contain at least one video. The `filteredItems` in `FilesContext` should already be populated by the search, so VideoWall will pick them up automatically.
**Checkpoint**: VideoWall works with both folder navigation and search/filter results
---
## Phase 6: User Story 4 - Background Preview Generation (Priority: P3)
**Goal**: Preview clips are generated proactively during file watching so most are ready before users open VideoWall.
**Independent Test**: Add new video files to a monitored folder, wait for file watcher scan cycle, confirm preview clips appear in `PREVIEW_CLIPS_DIRECTORY` without any user request.
### Implementation for User Story 4
- [x] T024 [US4] Extend `process_new_files()` in `src/main.rs` to detect videos missing preview clips. After the existing EXIF batch query, add a batch query via `preview_dao.get_previews_batch()` for all discovered video paths. Collect videos that have no record or have `failed` status (for retry).
- [x] T025 [US4] Queue preview generation for new/unprocessed videos in `process_new_files()` in `src/main.rs`. For each video missing a preview, insert a `pending` record via `preview_dao.insert_preview()` (skip if already exists), then send `GeneratePreviewMessage` to the `PreviewClipGenerator` actor. Follow the existing pattern of sending `QueueVideosMessage` to `VideoPlaylistManager`.
- [x] T026 [US4] Add preview clip directory creation to startup scan in `src/main.rs`. During the initial startup thumbnail generation phase, also check for videos missing preview clips and queue them for generation (same logic as T024/T025 but for the initial full scan). Ensure the `PREVIEW_CLIPS_DIRECTORY` is created at startup if it doesn't exist.
**Checkpoint**: New videos automatically get preview clips generated during file watcher scans
---
## Phase 7: Polish & Cross-Cutting Concerns
**Purpose**: Error handling, loading states, observability
- [x] T027 [P] Add loading/placeholder state for pending previews in `components/VideoWallItem.tsx` (SynologyFileViewer). Show the existing thumbnail from `/image?path=&size=thumb` with a semi-transparent overlay and a loading spinner when preview status is `pending` or `processing`.
- [x] T028 [P] Add error state for failed previews in `components/VideoWallItem.tsx` (SynologyFileViewer). Show the existing thumbnail with an error icon overlay and optional "Retry" text when preview status is `failed`.
- [x] T029 [P] Add OpenTelemetry tracing spans for preview generation in `src/video/actors.rs` and `src/main.rs` endpoints. Follow the existing pattern of `global_tracer().start("preview_clip_generate")` with status and duration attributes.
- [x] T030 Verify cargo build and cargo clippy pass with all backend changes. Fix any warnings or errors.
- [x] T031 Run quickstart.md validation: test both API endpoints manually with curl, verify preview clip file is generated in correct directory structure, confirm mobile app connects and displays VideoWall.
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies — start immediately
- **Foundational (Phase 2)**: Depends on Phase 1 (migration must run first)
- **US2 - Server Generation (Phase 3)**: Depends on Phase 2 (needs DAO, models, types)
- **US1 - Mobile VideoWall (Phase 4)**: Depends on Phase 3 (needs API endpoints to exist)
- **US3 - Search Results (Phase 5)**: Depends on Phase 4 (extends VideoWall view)
- **US4 - Background Generation (Phase 6)**: Depends on Phase 3 only (backend only, no mobile dependency)
- **Polish (Phase 7)**: Depends on Phases 4 and 6
### User Story Dependencies
- **US2 (P1)**: Can start after Foundational — no other story dependencies
- **US1 (P1)**: Depends on US2 (needs preview API endpoints)
- **US3 (P2)**: Depends on US1 (extends the VideoWall view)
- **US4 (P3)**: Depends on US2 only (extends file watcher with preview generation; independent of mobile app)
### Within Each User Story
- Models/types before services/DAO
- DAO before actors
- Actors before endpoints
- Backend endpoints before mobile app views
- Core view before navigation integration
### Parallel Opportunities
**Phase 2**: T004, T005 can run in parallel (different files)
**Phase 3**: T008, T009 can run in parallel (ffmpeg.rs vs actors.rs)
**Phase 4**: T015, T016 can run in parallel (hook vs component, different files)
**Phase 6**: T024, T025 are sequential (same file) but Phase 6 can run in parallel with Phase 4/5
**Phase 7**: T027, T028, T029 can all run in parallel (different files)
---
## Parallel Example: User Story 2
```bash
# Launch parallelizable tasks together:
Task T008: "Add generate_preview_clip() function in src/video/ffmpeg.rs"
Task T009: "Create PreviewClipGenerator actor in src/video/actors.rs"
# Then sequential tasks (depend on T008+T009):
Task T010: "Add PreviewClipGenerator to AppState in src/state.rs"
Task T011: "Implement GET /video/preview handler in src/main.rs"
Task T012: "Implement POST /video/preview/status handler in src/main.rs"
Task T013: "Register endpoints in route configuration in src/main.rs"
Task T014: "Handle short videos in generate_preview_clip() in src/video/ffmpeg.rs"
```
## Parallel Example: User Story 1
```bash
# Launch parallelizable tasks together:
Task T015: "Create useVideoWall hook in hooks/useVideoWall.ts"
Task T016: "Create VideoWallItem component in components/VideoWallItem.tsx"
# Then sequential tasks (depend on T015+T016):
Task T017: "Create VideoWall view in app/(app)/grid/video-wall.tsx"
Task T018: "Add video-wall route to stack navigator"
Task T019: "Add navigation entry point from grid view"
Task T020: "Implement long-press audio-on-focus"
Task T021: "Implement tap-to-navigate to full video player"
```
---
## Implementation Strategy
### MVP First (US2 + US1)
1. Complete Phase 1: Setup (migration, env var)
2. Complete Phase 2: Foundational (model, DAO, types)
3. Complete Phase 3: US2 — Server generates preview clips
4. **STOP and VALIDATE**: Test API with curl per quickstart.md
5. Complete Phase 4: US1 — Mobile VideoWall view
6. **STOP and VALIDATE**: Test end-to-end on device
7. Deploy/demo — this is the MVP!
### Incremental Delivery
1. Setup + Foundational → Foundation ready
2. US2 (Server Generation) → Backend API working (testable with curl)
3. US1 (Mobile VideoWall) → Full end-to-end MVP (testable on device)
4. US3 (Search Results) → Extended browsing from search (incremental value)
5. US4 (Background Generation) → Performance enhancement (clips pre-generated)
6. Polish → Error states, tracing, validation
### Note on US4 Parallelism
US4 (Background Generation) only depends on US2 (backend), not on the mobile app. It can be developed in parallel with US1 by a second developer, or deferred to after MVP is validated.
---
## Notes
- [P] tasks = different files, no dependencies
- [Story] label maps task to specific user story
- Backend work is in `C:\Users\ccord\RustroverProjects\ImageApi`
- Frontend work is in `C:\Users\ccord\development\SynologyFileViewer`
- Commit after each task or logical group
- Stop at any checkpoint to validate story independently

View File

@@ -371,6 +371,29 @@ pub struct GpsPhotosResponse {
pub total: usize, pub total: usize,
} }
#[derive(Deserialize)]
pub struct PreviewClipRequest {
pub path: String,
}
#[derive(Deserialize)]
pub struct PreviewStatusRequest {
pub paths: Vec<String>,
}
#[derive(Serialize)]
pub struct PreviewStatusResponse {
pub previews: Vec<PreviewStatusItem>,
}
#[derive(Serialize)]
pub struct PreviewStatusItem {
pub path: String,
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub preview_url: Option<String>,
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::Claims; use super::Claims;
@@ -419,4 +442,54 @@ mod tests {
} }
} }
} }
#[test]
fn test_preview_clip_request_deserialize() {
use super::PreviewClipRequest;
let json = r#"{"path":"photos/2024/video.mp4"}"#;
let req: PreviewClipRequest = serde_json::from_str(json).unwrap();
assert_eq!(req.path, "photos/2024/video.mp4");
}
#[test]
fn test_preview_status_request_deserialize() {
use super::PreviewStatusRequest;
let json = r#"{"paths":["a/one.mp4","b/two.mp4","c/three.mp4"]}"#;
let req: PreviewStatusRequest = serde_json::from_str(json).unwrap();
assert_eq!(req.paths.len(), 3);
assert_eq!(req.paths[0], "a/one.mp4");
assert_eq!(req.paths[2], "c/three.mp4");
}
#[test]
fn test_preview_status_response_serialize() {
use super::{PreviewStatusItem, PreviewStatusResponse};
let response = PreviewStatusResponse {
previews: vec![
PreviewStatusItem {
path: "a/one.mp4".to_string(),
status: "complete".to_string(),
preview_url: Some("/video/preview?path=a%2Fone.mp4".to_string()),
},
PreviewStatusItem {
path: "b/two.mp4".to_string(),
status: "pending".to_string(),
preview_url: None,
},
],
};
let json = serde_json::to_value(&response).unwrap();
let previews = json["previews"].as_array().unwrap();
assert_eq!(previews.len(), 2);
// Complete item should have preview_url
assert_eq!(previews[0]["status"], "complete");
assert!(previews[0]["preview_url"].is_string());
// Pending item should not have preview_url (skip_serializing_if)
assert_eq!(previews[1]["status"], "pending");
assert!(previews[1].get("preview_url").is_none());
}
} }

View File

@@ -14,6 +14,7 @@ pub mod daily_summary_dao;
pub mod insights_dao; pub mod insights_dao;
pub mod location_dao; pub mod location_dao;
pub mod models; pub mod models;
pub mod preview_dao;
pub mod schema; pub mod schema;
pub mod search_dao; pub mod search_dao;
@@ -21,6 +22,7 @@ pub use calendar_dao::{CalendarEventDao, SqliteCalendarEventDao};
pub use daily_summary_dao::{DailySummaryDao, InsertDailySummary, SqliteDailySummaryDao}; pub use daily_summary_dao::{DailySummaryDao, InsertDailySummary, SqliteDailySummaryDao};
pub use insights_dao::{InsightDao, SqliteInsightDao}; pub use insights_dao::{InsightDao, SqliteInsightDao};
pub use location_dao::{LocationHistoryDao, SqliteLocationHistoryDao}; pub use location_dao::{LocationHistoryDao, SqliteLocationHistoryDao};
pub use preview_dao::{PreviewDao, SqlitePreviewDao};
pub use search_dao::{SearchHistoryDao, SqliteSearchHistoryDao}; pub use search_dao::{SearchHistoryDao, SqliteSearchHistoryDao};
pub trait UserDao { pub trait UserDao {

View File

@@ -1,4 +1,4 @@
use crate::database::schema::{favorites, image_exif, photo_insights, users}; use crate::database::schema::{favorites, image_exif, photo_insights, users, video_preview_clips};
use serde::Serialize; use serde::Serialize;
#[derive(Insertable)] #[derive(Insertable)]
@@ -93,3 +93,24 @@ pub struct PhotoInsight {
pub generated_at: i64, pub generated_at: i64,
pub model_version: String, pub model_version: String,
} }
#[derive(Insertable)]
#[diesel(table_name = video_preview_clips)]
pub struct InsertVideoPreviewClip {
pub file_path: String,
pub status: String,
pub created_at: String,
pub updated_at: String,
}
#[derive(Serialize, Queryable, Clone, Debug)]
pub struct VideoPreviewClip {
pub id: i32,
pub file_path: String,
pub status: String,
pub duration_seconds: Option<f32>,
pub file_size_bytes: Option<i32>,
pub error_message: Option<String>,
pub created_at: String,
pub updated_at: String,
}

354
src/database/preview_dao.rs Normal file
View File

@@ -0,0 +1,354 @@
use diesel::prelude::*;
use diesel::sqlite::SqliteConnection;
use std::ops::DerefMut;
use std::sync::{Arc, Mutex};
use crate::database::models::{InsertVideoPreviewClip, VideoPreviewClip};
use crate::database::{connect, DbError, DbErrorKind};
use crate::otel::trace_db_call;
pub trait PreviewDao: Sync + Send {
fn insert_preview(
&mut self,
context: &opentelemetry::Context,
file_path_val: &str,
status_val: &str,
) -> Result<(), DbError>;
fn update_status(
&mut self,
context: &opentelemetry::Context,
file_path_val: &str,
status_val: &str,
duration: Option<f32>,
size: Option<i32>,
error: Option<&str>,
) -> Result<(), DbError>;
fn get_preview(
&mut self,
context: &opentelemetry::Context,
file_path_val: &str,
) -> Result<Option<VideoPreviewClip>, DbError>;
fn get_previews_batch(
&mut self,
context: &opentelemetry::Context,
file_paths: &[String],
) -> Result<Vec<VideoPreviewClip>, DbError>;
fn get_by_status(
&mut self,
context: &opentelemetry::Context,
status_val: &str,
) -> Result<Vec<VideoPreviewClip>, DbError>;
}
pub struct SqlitePreviewDao {
connection: Arc<Mutex<SqliteConnection>>,
}
impl Default for SqlitePreviewDao {
fn default() -> Self {
Self::new()
}
}
impl SqlitePreviewDao {
pub fn new() -> Self {
SqlitePreviewDao {
connection: Arc::new(Mutex::new(connect())),
}
}
#[cfg(test)]
pub fn from_connection(conn: SqliteConnection) -> Self {
SqlitePreviewDao {
connection: Arc::new(Mutex::new(conn)),
}
}
}
impl PreviewDao for SqlitePreviewDao {
fn insert_preview(
&mut self,
context: &opentelemetry::Context,
file_path_val: &str,
status_val: &str,
) -> Result<(), DbError> {
trace_db_call(context, "insert", "insert_preview", |_span| {
use crate::database::schema::video_preview_clips::dsl::*;
let mut connection = self.connection.lock().expect("Unable to get PreviewDao");
let now = chrono::Utc::now().to_rfc3339();
diesel::insert_or_ignore_into(video_preview_clips)
.values(InsertVideoPreviewClip {
file_path: file_path_val.to_string(),
status: status_val.to_string(),
created_at: now.clone(),
updated_at: now,
})
.execute(connection.deref_mut())
.map(|_| ())
.map_err(|e| anyhow::anyhow!("Insert error: {}", e))
})
.map_err(|_| DbError::new(DbErrorKind::InsertError))
}
fn update_status(
&mut self,
context: &opentelemetry::Context,
file_path_val: &str,
status_val: &str,
duration: Option<f32>,
size: Option<i32>,
error: Option<&str>,
) -> Result<(), DbError> {
trace_db_call(context, "update", "update_preview_status", |_span| {
use crate::database::schema::video_preview_clips::dsl::*;
let mut connection = self.connection.lock().expect("Unable to get PreviewDao");
let now = chrono::Utc::now().to_rfc3339();
diesel::update(video_preview_clips.filter(file_path.eq(file_path_val)))
.set((
status.eq(status_val),
duration_seconds.eq(duration),
file_size_bytes.eq(size),
error_message.eq(error),
updated_at.eq(&now),
))
.execute(connection.deref_mut())
.map(|_| ())
.map_err(|e| anyhow::anyhow!("Update error: {}", e))
})
.map_err(|_| DbError::new(DbErrorKind::UpdateError))
}
fn get_preview(
&mut self,
context: &opentelemetry::Context,
file_path_val: &str,
) -> Result<Option<VideoPreviewClip>, DbError> {
trace_db_call(context, "query", "get_preview", |_span| {
use crate::database::schema::video_preview_clips::dsl::*;
let mut connection = self.connection.lock().expect("Unable to get PreviewDao");
match video_preview_clips
.filter(file_path.eq(file_path_val))
.first::<VideoPreviewClip>(connection.deref_mut())
{
Ok(clip) => Ok(Some(clip)),
Err(diesel::result::Error::NotFound) => Ok(None),
Err(e) => Err(anyhow::anyhow!("Query error: {}", e)),
}
})
.map_err(|_| DbError::new(DbErrorKind::QueryError))
}
fn get_previews_batch(
&mut self,
context: &opentelemetry::Context,
file_paths: &[String],
) -> Result<Vec<VideoPreviewClip>, DbError> {
trace_db_call(context, "query", "get_previews_batch", |_span| {
use crate::database::schema::video_preview_clips::dsl::*;
if file_paths.is_empty() {
return Ok(Vec::new());
}
let mut connection = self.connection.lock().expect("Unable to get PreviewDao");
video_preview_clips
.filter(file_path.eq_any(file_paths))
.load::<VideoPreviewClip>(connection.deref_mut())
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
})
.map_err(|_| DbError::new(DbErrorKind::QueryError))
}
fn get_by_status(
&mut self,
context: &opentelemetry::Context,
status_val: &str,
) -> Result<Vec<VideoPreviewClip>, DbError> {
trace_db_call(context, "query", "get_previews_by_status", |_span| {
use crate::database::schema::video_preview_clips::dsl::*;
let mut connection = self.connection.lock().expect("Unable to get PreviewDao");
video_preview_clips
.filter(status.eq(status_val))
.load::<VideoPreviewClip>(connection.deref_mut())
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
})
.map_err(|_| DbError::new(DbErrorKind::QueryError))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::database::test::in_memory_db_connection;
fn setup_dao() -> SqlitePreviewDao {
SqlitePreviewDao::from_connection(in_memory_db_connection())
}
fn ctx() -> opentelemetry::Context {
opentelemetry::Context::new()
}
#[test]
fn test_insert_and_get_preview() {
let mut dao = setup_dao();
let ctx = ctx();
dao.insert_preview(&ctx, "photos/video.mp4", "pending")
.unwrap();
let result = dao.get_preview(&ctx, "photos/video.mp4").unwrap();
assert!(result.is_some());
let clip = result.unwrap();
assert_eq!(clip.file_path, "photos/video.mp4");
assert_eq!(clip.status, "pending");
assert!(clip.duration_seconds.is_none());
assert!(clip.file_size_bytes.is_none());
assert!(clip.error_message.is_none());
}
#[test]
fn test_insert_duplicate_ignored() {
let mut dao = setup_dao();
let ctx = ctx();
dao.insert_preview(&ctx, "photos/video.mp4", "pending")
.unwrap();
// Second insert with same path should not error (INSERT OR IGNORE)
dao.insert_preview(&ctx, "photos/video.mp4", "processing")
.unwrap();
// Status should remain "pending" from the first insert
let clip = dao
.get_preview(&ctx, "photos/video.mp4")
.unwrap()
.unwrap();
assert_eq!(clip.status, "pending");
}
#[test]
fn test_update_status_to_complete() {
let mut dao = setup_dao();
let ctx = ctx();
dao.insert_preview(&ctx, "photos/video.mp4", "pending")
.unwrap();
dao.update_status(
&ctx,
"photos/video.mp4",
"complete",
Some(9.5),
Some(1024000),
None,
)
.unwrap();
let clip = dao
.get_preview(&ctx, "photos/video.mp4")
.unwrap()
.unwrap();
assert_eq!(clip.status, "complete");
assert_eq!(clip.duration_seconds, Some(9.5));
assert_eq!(clip.file_size_bytes, Some(1024000));
assert!(clip.error_message.is_none());
}
#[test]
fn test_update_status_to_failed() {
let mut dao = setup_dao();
let ctx = ctx();
dao.insert_preview(&ctx, "photos/video.mp4", "pending")
.unwrap();
dao.update_status(
&ctx,
"photos/video.mp4",
"failed",
None,
None,
Some("ffmpeg exited with code 1"),
)
.unwrap();
let clip = dao
.get_preview(&ctx, "photos/video.mp4")
.unwrap()
.unwrap();
assert_eq!(clip.status, "failed");
assert_eq!(
clip.error_message.as_deref(),
Some("ffmpeg exited with code 1")
);
}
#[test]
fn test_get_preview_not_found() {
let mut dao = setup_dao();
let ctx = ctx();
let result = dao.get_preview(&ctx, "nonexistent/path.mp4").unwrap();
assert!(result.is_none());
}
#[test]
fn test_get_previews_batch() {
let mut dao = setup_dao();
let ctx = ctx();
dao.insert_preview(&ctx, "a/one.mp4", "complete").unwrap();
dao.insert_preview(&ctx, "b/two.mp4", "pending").unwrap();
dao.insert_preview(&ctx, "c/three.mp4", "failed").unwrap();
// Query only two of the three
let paths = vec!["a/one.mp4".to_string(), "c/three.mp4".to_string()];
let results = dao.get_previews_batch(&ctx, &paths).unwrap();
assert_eq!(results.len(), 2);
let statuses: Vec<&str> = results.iter().map(|c| c.status.as_str()).collect();
assert!(statuses.contains(&"complete"));
assert!(statuses.contains(&"failed"));
}
#[test]
fn test_get_previews_batch_empty_input() {
let mut dao = setup_dao();
let ctx = ctx();
let results = dao.get_previews_batch(&ctx, &[]).unwrap();
assert!(results.is_empty());
}
#[test]
fn test_get_by_status() {
let mut dao = setup_dao();
let ctx = ctx();
dao.insert_preview(&ctx, "a.mp4", "pending").unwrap();
dao.insert_preview(&ctx, "b.mp4", "complete").unwrap();
dao.insert_preview(&ctx, "c.mp4", "pending").unwrap();
dao.insert_preview(&ctx, "d.mp4", "failed").unwrap();
let pending = dao.get_by_status(&ctx, "pending").unwrap();
assert_eq!(pending.len(), 2);
let complete = dao.get_by_status(&ctx, "complete").unwrap();
assert_eq!(complete.len(), 1);
assert_eq!(complete[0].file_path, "b.mp4");
let processing = dao.get_by_status(&ctx, "processing").unwrap();
assert!(processing.is_empty());
}
}

View File

@@ -152,6 +152,19 @@ diesel::table! {
} }
} }
diesel::table! {
video_preview_clips (id) {
id -> Integer,
file_path -> Text,
status -> Text,
duration_seconds -> Nullable<Float>,
file_size_bytes -> Nullable<Integer>,
error_message -> Nullable<Text>,
created_at -> Text,
updated_at -> Text,
}
}
diesel::joinable!(tagged_photo -> tags (tag_id)); diesel::joinable!(tagged_photo -> tags (tag_id));
diesel::allow_tables_to_appear_in_same_query!( diesel::allow_tables_to_appear_in_same_query!(
@@ -167,4 +180,5 @@ diesel::allow_tables_to_appear_in_same_query!(
tagged_photo, tagged_photo,
tags, tags,
users, users,
video_preview_clips,
); );

View File

@@ -46,8 +46,8 @@ use crate::service::ServiceBuilder;
use crate::state::AppState; use crate::state::AppState;
use crate::tags::*; use crate::tags::*;
use crate::video::actors::{ use crate::video::actors::{
ProcessMessage, QueueVideosMessage, ScanDirectoryMessage, VideoPlaylistManager, GeneratePreviewClipMessage, ProcessMessage, QueueVideosMessage, ScanDirectoryMessage,
create_playlist, generate_video_thumbnail, VideoPlaylistManager, create_playlist, generate_video_thumbnail,
}; };
use log::{debug, error, info, trace, warn}; use log::{debug, error, info, trace, warn};
use opentelemetry::trace::{Span, Status, TraceContextExt, Tracer}; use opentelemetry::trace::{Span, Status, TraceContextExt, Tracer};
@@ -176,7 +176,7 @@ fn is_video_file(path: &Path) -> bool {
async fn create_circular_thumbnail( async fn create_circular_thumbnail(
thumb_path: &Path, thumb_path: &Path,
thumbs_dir: &str, thumbs_dir: &str,
) -> Result<PathBuf, Box<dyn std::error::Error>> { ) -> Result<PathBuf, Box<dyn Error>> {
use image::{GenericImageView, ImageBuffer, Rgba}; use image::{GenericImageView, ImageBuffer, Rgba};
// Create circular thumbnails directory // Create circular thumbnails directory
@@ -583,6 +583,239 @@ async fn get_video_part(
} }
} }
#[get("/video/preview")]
async fn get_video_preview(
_claims: Claims,
request: HttpRequest,
req: web::Query<PreviewClipRequest>,
app_state: Data<AppState>,
preview_dao: Data<Mutex<Box<dyn PreviewDao>>>,
) -> impl Responder {
let tracer = global_tracer();
let context = extract_context_from_request(&request);
let mut span = tracer.start_with_context("get_video_preview", &context);
// Validate path
let full_path = match is_valid_full_path(&app_state.base_path, &req.path, true) {
Some(path) => path,
None => {
span.set_status(Status::error("Invalid path"));
return HttpResponse::BadRequest()
.json(serde_json::json!({"error": "Invalid path"}));
}
};
let full_path_str = full_path.to_string_lossy().to_string();
// Use relative path (from BASE_PATH) for DB storage, consistent with EXIF convention
let relative_path = full_path_str
.strip_prefix(&app_state.base_path)
.unwrap_or(&full_path_str)
.trim_start_matches(['/', '\\'])
.to_string();
// Check preview status in DB
let preview = {
let mut dao = preview_dao.lock().expect("Unable to lock PreviewDao");
dao.get_preview(&context, &relative_path)
};
match preview {
Ok(Some(clip)) => match clip.status.as_str() {
"complete" => {
let preview_path = PathBuf::from(&app_state.preview_clips_path)
.join(&relative_path)
.with_extension("mp4");
match NamedFile::open(&preview_path) {
Ok(file) => {
span.set_status(Status::Ok);
file.into_response(&request)
}
Err(_) => {
// File missing on disk but DB says complete - reset and regenerate
let mut dao =
preview_dao.lock().expect("Unable to lock PreviewDao");
let _ = dao.update_status(
&context,
&relative_path,
"pending",
None,
None,
None,
);
app_state
.preview_clip_generator
.do_send(GeneratePreviewClipMessage {
video_path: full_path_str,
});
span.set_status(Status::Ok);
HttpResponse::Accepted().json(serde_json::json!({
"status": "processing",
"path": req.path
}))
}
}
}
"processing" => {
span.set_status(Status::Ok);
HttpResponse::Accepted().json(serde_json::json!({
"status": "processing",
"path": req.path
}))
}
"failed" => {
let error_msg =
clip.error_message.unwrap_or_else(|| "Unknown error".to_string());
span.set_status(Status::error(format!(
"Generation failed: {}",
error_msg
)));
HttpResponse::InternalServerError().json(serde_json::json!({
"error": format!("Generation failed: {}", error_msg)
}))
}
_ => {
// pending or unknown status - trigger generation
app_state
.preview_clip_generator
.do_send(GeneratePreviewClipMessage {
video_path: full_path_str,
});
span.set_status(Status::Ok);
HttpResponse::Accepted().json(serde_json::json!({
"status": "processing",
"path": req.path
}))
}
},
Ok(None) => {
// No record exists - insert as pending and trigger generation
{
let mut dao = preview_dao.lock().expect("Unable to lock PreviewDao");
let _ = dao.insert_preview(&context, &relative_path, "pending");
}
app_state
.preview_clip_generator
.do_send(GeneratePreviewClipMessage {
video_path: full_path_str,
});
span.set_status(Status::Ok);
HttpResponse::Accepted().json(serde_json::json!({
"status": "processing",
"path": req.path
}))
}
Err(_) => {
span.set_status(Status::error("Database error"));
HttpResponse::InternalServerError()
.json(serde_json::json!({"error": "Database error"}))
}
}
}
#[post("/video/preview/status")]
async fn get_preview_status(
_claims: Claims,
request: HttpRequest,
body: web::Json<PreviewStatusRequest>,
app_state: Data<AppState>,
preview_dao: Data<Mutex<Box<dyn PreviewDao>>>,
) -> impl Responder {
let tracer = global_tracer();
let context = extract_context_from_request(&request);
let mut span = tracer.start_with_context("get_preview_status", &context);
// Limit to 200 paths per request
if body.paths.len() > 200 {
span.set_status(Status::error("Too many paths"));
return HttpResponse::BadRequest()
.json(serde_json::json!({"error": "Maximum 200 paths per request"}));
}
let previews = {
let mut dao = preview_dao.lock().expect("Unable to lock PreviewDao");
dao.get_previews_batch(&context, &body.paths)
};
match previews {
Ok(clips) => {
// Build a map of file_path -> VideoPreviewClip for quick lookup
let clip_map: HashMap<String, _> = clips
.into_iter()
.map(|clip| (clip.file_path.clone(), clip))
.collect();
let mut items: Vec<PreviewStatusItem> = Vec::with_capacity(body.paths.len());
for path in &body.paths {
if let Some(clip) = clip_map.get(path) {
// Re-queue generation for stale pending/failed records
if clip.status == "pending" || clip.status == "failed" {
let full_path = format!(
"{}/{}",
app_state.base_path.trim_end_matches(['/', '\\']),
path.trim_start_matches(['/', '\\'])
);
app_state
.preview_clip_generator
.do_send(GeneratePreviewClipMessage {
video_path: full_path,
});
}
items.push(PreviewStatusItem {
path: path.clone(),
status: clip.status.clone(),
preview_url: if clip.status == "complete" {
Some(format!(
"/video/preview?path={}",
urlencoding::encode(path)
))
} else {
None
},
});
} else {
// No record exists — insert as pending and trigger generation
{
let mut dao = preview_dao.lock().expect("Unable to lock PreviewDao");
let _ = dao.insert_preview(&context, path, "pending");
}
// Build full path for ffmpeg (actor needs the absolute path for input)
let full_path = format!(
"{}/{}",
app_state.base_path.trim_end_matches(['/', '\\']),
path.trim_start_matches(['/', '\\'])
);
info!("Triggering preview generation for '{}'", path);
app_state
.preview_clip_generator
.do_send(GeneratePreviewClipMessage {
video_path: full_path,
});
items.push(PreviewStatusItem {
path: path.clone(),
status: "pending".to_string(),
preview_url: None,
});
}
}
span.set_status(Status::Ok);
HttpResponse::Ok().json(PreviewStatusResponse { previews: items })
}
Err(_) => {
span.set_status(Status::error("Database error"));
HttpResponse::InternalServerError()
.json(serde_json::json!({"error": "Database error"}))
}
}
}
#[get("image/favorites")] #[get("image/favorites")]
async fn favorites( async fn favorites(
claims: Claims, claims: Claims,
@@ -836,9 +1069,10 @@ fn main() -> std::io::Result<()> {
directory: app_state.base_path.clone(), directory: app_state.base_path.clone(),
}); });
// Start file watcher with playlist manager // Start file watcher with playlist manager and preview generator
let playlist_mgr_for_watcher = app_state.playlist_manager.as_ref().clone(); let playlist_mgr_for_watcher = app_state.playlist_manager.as_ref().clone();
watch_files(playlist_mgr_for_watcher); let preview_gen_for_watcher = app_state.preview_clip_generator.as_ref().clone();
watch_files(playlist_mgr_for_watcher, preview_gen_for_watcher);
// Start orphaned playlist cleanup job // Start orphaned playlist cleanup job
cleanup_orphaned_playlists(); cleanup_orphaned_playlists();
@@ -855,7 +1089,8 @@ fn main() -> std::io::Result<()> {
let start_date = Some(NaiveDate::from_ymd_opt(2015, 10, 1).unwrap()); let start_date = Some(NaiveDate::from_ymd_opt(2015, 10, 1).unwrap());
let end_date = Some(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap()); let end_date = Some(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap());
let contacts_to_summarize = vec!["Domenique", "Zach", "Paul"]; // Add more contacts as needed // let contacts_to_summarize = vec!["Domenique", "Zach", "Paul"]; // Add more contacts as needed
let contacts_to_summarize = vec![]; // Add more contacts as needed
let ollama = app_state.ollama.clone(); let ollama = app_state.ollama.clone();
let sms_client = app_state.sms_client.clone(); let sms_client = app_state.sms_client.clone();
@@ -870,7 +1105,7 @@ fn main() -> std::io::Result<()> {
let end = end_date; let end = end_date;
tokio::spawn(async move { tokio::spawn(async move {
log::info!("Starting daily summary generation for {}", contact); info!("Starting daily summary generation for {}", contact);
if let Err(e) = generate_daily_summaries( if let Err(e) = generate_daily_summaries(
contact, contact,
start, start,
@@ -881,9 +1116,9 @@ fn main() -> std::io::Result<()> {
) )
.await .await
{ {
log::error!("Daily summary generation failed for {}: {:?}", contact, e); error!("Daily summary generation failed for {}: {:?}", contact, e);
} else { } else {
log::info!("Daily summary generation completed for {}", contact); info!("Daily summary generation completed for {}", contact);
} }
}); });
} }
@@ -895,6 +1130,7 @@ fn main() -> std::io::Result<()> {
let tag_dao = SqliteTagDao::default(); let tag_dao = SqliteTagDao::default();
let exif_dao = SqliteExifDao::new(); let exif_dao = SqliteExifDao::new();
let insight_dao = SqliteInsightDao::new(); let insight_dao = SqliteInsightDao::new();
let preview_dao = SqlitePreviewDao::new();
let cors = Cors::default() let cors = Cors::default()
.allowed_origin_fn(|origin, _req_head| { .allowed_origin_fn(|origin, _req_head| {
// Allow all origins in development, or check against CORS_ALLOWED_ORIGINS env var // Allow all origins in development, or check against CORS_ALLOWED_ORIGINS env var
@@ -944,6 +1180,8 @@ fn main() -> std::io::Result<()> {
.service(upload_image) .service(upload_image)
.service(generate_video) .service(generate_video)
.service(stream_video) .service(stream_video)
.service(get_video_preview)
.service(get_preview_status)
.service(get_video_part) .service(get_video_part)
.service(favorites) .service(favorites)
.service(put_add_favorite) .service(put_add_favorite)
@@ -971,6 +1209,24 @@ fn main() -> std::io::Result<()> {
.app_data::<Data<Mutex<Box<dyn InsightDao>>>>(Data::new(Mutex::new(Box::new( .app_data::<Data<Mutex<Box<dyn InsightDao>>>>(Data::new(Mutex::new(Box::new(
insight_dao, insight_dao,
)))) ))))
.app_data::<Data<Mutex<Box<dyn PreviewDao>>>>(Data::new(Mutex::new(Box::new(
preview_dao,
))))
.app_data(
web::JsonConfig::default()
.error_handler(|err, req| {
let detail = err.to_string();
log::warn!(
"JSON parse error on {} {}: {}",
req.method(),
req.uri(),
detail
);
let response = HttpResponse::BadRequest()
.json(serde_json::json!({"error": detail}));
actix_web::error::InternalError::from_response(err, response).into()
}),
)
.app_data::<Data<InsightGenerator>>(Data::new(app_data.insight_generator.clone())) .app_data::<Data<InsightGenerator>>(Data::new(app_data.insight_generator.clone()))
.wrap(prometheus.clone()) .wrap(prometheus.clone())
}) })
@@ -1118,7 +1374,10 @@ fn cleanup_orphaned_playlists() {
}); });
} }
fn watch_files(playlist_manager: Addr<VideoPlaylistManager>) { fn watch_files(
playlist_manager: Addr<VideoPlaylistManager>,
preview_generator: Addr<video::actors::PreviewClipGenerator>,
) {
std::thread::spawn(move || { std::thread::spawn(move || {
let base_str = dotenv::var("BASE_PATH").unwrap(); let base_str = dotenv::var("BASE_PATH").unwrap();
let base_path = PathBuf::from(&base_str); let base_path = PathBuf::from(&base_str);
@@ -1141,10 +1400,13 @@ fn watch_files(playlist_manager: Addr<VideoPlaylistManager>) {
info!(" Full scan interval: {} seconds", full_interval_secs); info!(" Full scan interval: {} seconds", full_interval_secs);
info!(" Watching directory: {}", base_str); info!(" Watching directory: {}", base_str);
// Create EXIF DAO for tracking processed files // Create DAOs for tracking processed files
let exif_dao = Arc::new(Mutex::new( let exif_dao = Arc::new(Mutex::new(
Box::new(SqliteExifDao::new()) as Box<dyn ExifDao> Box::new(SqliteExifDao::new()) as Box<dyn ExifDao>
)); ));
let preview_dao = Arc::new(Mutex::new(
Box::new(SqlitePreviewDao::new()) as Box<dyn PreviewDao>
));
let mut last_quick_scan = SystemTime::now(); let mut last_quick_scan = SystemTime::now();
let mut last_full_scan = SystemTime::now(); let mut last_full_scan = SystemTime::now();
@@ -1165,8 +1427,10 @@ fn watch_files(playlist_manager: Addr<VideoPlaylistManager>) {
process_new_files( process_new_files(
&base_path, &base_path,
Arc::clone(&exif_dao), Arc::clone(&exif_dao),
Arc::clone(&preview_dao),
None, None,
playlist_manager.clone(), playlist_manager.clone(),
preview_generator.clone(),
); );
last_full_scan = now; last_full_scan = now;
} else { } else {
@@ -1181,8 +1445,10 @@ fn watch_files(playlist_manager: Addr<VideoPlaylistManager>) {
process_new_files( process_new_files(
&base_path, &base_path,
Arc::clone(&exif_dao), Arc::clone(&exif_dao),
Arc::clone(&preview_dao),
Some(check_since), Some(check_since),
playlist_manager.clone(), playlist_manager.clone(),
preview_generator.clone(),
); );
} }
@@ -1221,8 +1487,10 @@ fn playlist_needs_generation(video_path: &Path, playlist_path: &Path) -> bool {
fn process_new_files( fn process_new_files(
base_path: &Path, base_path: &Path,
exif_dao: Arc<Mutex<Box<dyn ExifDao>>>, exif_dao: Arc<Mutex<Box<dyn ExifDao>>>,
preview_dao: Arc<Mutex<Box<dyn PreviewDao>>>,
modified_since: Option<SystemTime>, modified_since: Option<SystemTime>,
playlist_manager: Addr<VideoPlaylistManager>, playlist_manager: Addr<VideoPlaylistManager>,
preview_generator: Addr<video::actors::PreviewClipGenerator>,
) { ) {
let context = opentelemetry::Context::new(); let context = opentelemetry::Context::new();
let thumbs = dotenv::var("THUMBNAILS").expect("THUMBNAILS not defined"); let thumbs = dotenv::var("THUMBNAILS").expect("THUMBNAILS not defined");
@@ -1385,9 +1653,294 @@ fn process_new_files(
}); });
} }
// Check for videos that need preview clips
// Collect (full_path, relative_path) for video files
let video_files: Vec<(String, String)> = files
.iter()
.filter(|(file_path, _)| is_video_file(file_path))
.map(|(file_path, rel_path)| (file_path.to_string_lossy().to_string(), rel_path.clone()))
.collect();
if !video_files.is_empty() {
// Query DB using relative paths (consistent with how GET/POST handlers store them)
let video_rel_paths: Vec<String> = video_files.iter().map(|(_, rel)| rel.clone()).collect();
let existing_previews: HashMap<String, String> = {
let mut dao = preview_dao.lock().expect("Unable to lock PreviewDao");
match dao.get_previews_batch(&context, &video_rel_paths) {
Ok(clips) => clips
.into_iter()
.map(|clip| (clip.file_path, clip.status))
.collect(),
Err(e) => {
error!("Error batch querying preview clips: {:?}", e);
HashMap::new()
}
}
};
for (full_path, relative_path) in &video_files {
let status = existing_previews.get(relative_path).map(|s| s.as_str());
let needs_preview = match status {
None => true, // No record at all
Some("failed") => true, // Retry failed
Some("pending") => true, // Stale pending from previous run
_ => false, // processing or complete
};
if needs_preview {
// Insert pending record using relative path
if status.is_none() {
let mut dao = preview_dao.lock().expect("Unable to lock PreviewDao");
let _ = dao.insert_preview(&context, relative_path, "pending");
}
// Send full path in the message — the actor will derive relative path from it
preview_generator.do_send(GeneratePreviewClipMessage {
video_path: full_path.clone(),
});
}
}
}
// Generate thumbnails for all files that need them // Generate thumbnails for all files that need them
if new_files_found { if new_files_found {
info!("Processing thumbnails for new files..."); info!("Processing thumbnails for new files...");
create_thumbnails(); create_thumbnails();
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::data::Claims;
use crate::database::PreviewDao;
use crate::testhelpers::TestPreviewDao;
use actix_web::web::Data;
fn make_token() -> String {
let claims = Claims::valid_user("1".to_string());
jsonwebtoken::encode(
&jsonwebtoken::Header::default(),
&claims,
&jsonwebtoken::EncodingKey::from_secret(b"test_key"),
)
.unwrap()
}
fn make_preview_dao(dao: TestPreviewDao) -> Data<Mutex<Box<dyn PreviewDao>>> {
Data::new(Mutex::new(Box::new(dao) as Box<dyn PreviewDao>))
}
#[actix_rt::test]
async fn test_get_preview_status_returns_pending_for_unknown() {
let dao = TestPreviewDao::new();
let preview_dao = make_preview_dao(dao);
let app_state = Data::new(AppState::test_state());
let token = make_token();
let app = actix_web::test::init_service(
App::new()
.service(get_preview_status)
.app_data(app_state)
.app_data(preview_dao.clone()),
)
.await;
let req = actix_web::test::TestRequest::post()
.uri("/video/preview/status")
.insert_header(("Authorization", format!("Bearer {}", token)))
.set_json(serde_json::json!({"paths": ["photos/new_video.mp4"]}))
.to_request();
let resp = actix_web::test::call_service(&app, req).await;
assert_eq!(resp.status(), 200);
let body: serde_json::Value = actix_web::test::read_body_json(resp).await;
let previews = body["previews"].as_array().unwrap();
assert_eq!(previews.len(), 1);
assert_eq!(previews[0]["status"], "pending");
// Verify the DAO now has a pending record
let mut dao_lock = preview_dao.lock().unwrap();
let ctx = opentelemetry::Context::new();
let clip = dao_lock
.get_preview(&ctx, "photos/new_video.mp4")
.unwrap();
assert!(clip.is_some());
assert_eq!(clip.unwrap().status, "pending");
}
#[actix_rt::test]
async fn test_get_preview_status_returns_complete_with_url() {
let mut dao = TestPreviewDao::new();
let ctx = opentelemetry::Context::new();
dao.insert_preview(&ctx, "photos/done.mp4", "pending")
.unwrap();
dao.update_status(&ctx, "photos/done.mp4", "complete", Some(9.5), Some(500000), None)
.unwrap();
let preview_dao = make_preview_dao(dao);
let app_state = Data::new(AppState::test_state());
let token = make_token();
let app = actix_web::test::init_service(
App::new()
.service(get_preview_status)
.app_data(app_state)
.app_data(preview_dao),
)
.await;
let req = actix_web::test::TestRequest::post()
.uri("/video/preview/status")
.insert_header(("Authorization", format!("Bearer {}", token)))
.set_json(serde_json::json!({"paths": ["photos/done.mp4"]}))
.to_request();
let resp = actix_web::test::call_service(&app, req).await;
assert_eq!(resp.status(), 200);
let body: serde_json::Value = actix_web::test::read_body_json(resp).await;
let previews = body["previews"].as_array().unwrap();
assert_eq!(previews.len(), 1);
assert_eq!(previews[0]["status"], "complete");
assert!(previews[0]["preview_url"].as_str().unwrap().contains("photos%2Fdone.mp4"));
}
#[actix_rt::test]
async fn test_get_preview_status_rejects_over_200_paths() {
let dao = TestPreviewDao::new();
let preview_dao = make_preview_dao(dao);
let app_state = Data::new(AppState::test_state());
let token = make_token();
let app = actix_web::test::init_service(
App::new()
.service(get_preview_status)
.app_data(app_state)
.app_data(preview_dao),
)
.await;
let paths: Vec<String> = (0..201).map(|i| format!("video_{}.mp4", i)).collect();
let req = actix_web::test::TestRequest::post()
.uri("/video/preview/status")
.insert_header(("Authorization", format!("Bearer {}", token)))
.set_json(serde_json::json!({"paths": paths}))
.to_request();
let resp = actix_web::test::call_service(&app, req).await;
assert_eq!(resp.status(), 400);
}
#[actix_rt::test]
async fn test_get_preview_status_mixed_statuses() {
let mut dao = TestPreviewDao::new();
let ctx = opentelemetry::Context::new();
dao.insert_preview(&ctx, "a.mp4", "pending").unwrap();
dao.insert_preview(&ctx, "b.mp4", "pending").unwrap();
dao.update_status(&ctx, "b.mp4", "complete", Some(10.0), Some(100000), None)
.unwrap();
let preview_dao = make_preview_dao(dao);
let app_state = Data::new(AppState::test_state());
let token = make_token();
let app = actix_web::test::init_service(
App::new()
.service(get_preview_status)
.app_data(app_state)
.app_data(preview_dao),
)
.await;
let req = actix_web::test::TestRequest::post()
.uri("/video/preview/status")
.insert_header(("Authorization", format!("Bearer {}", token)))
.set_json(serde_json::json!({"paths": ["a.mp4", "b.mp4", "c.mp4"]}))
.to_request();
let resp = actix_web::test::call_service(&app, req).await;
assert_eq!(resp.status(), 200);
let body: serde_json::Value = actix_web::test::read_body_json(resp).await;
let previews = body["previews"].as_array().unwrap();
assert_eq!(previews.len(), 3);
// a.mp4 is pending
assert_eq!(previews[0]["path"], "a.mp4");
assert_eq!(previews[0]["status"], "pending");
// b.mp4 is complete with URL
assert_eq!(previews[1]["path"], "b.mp4");
assert_eq!(previews[1]["status"], "complete");
assert!(previews[1]["preview_url"].is_string());
// c.mp4 was not found — handler inserts pending
assert_eq!(previews[2]["path"], "c.mp4");
assert_eq!(previews[2]["status"], "pending");
}
/// Verifies that the status endpoint re-queues generation for stale
/// "pending" and "failed" records (e.g., after a server restart or
/// when clip files were deleted). The do_send to the actor exercises
/// the re-queue code path; the actor runs against temp dirs so it
/// won't panic.
#[actix_rt::test]
async fn test_get_preview_status_requeues_pending_and_failed() {
let mut dao = TestPreviewDao::new();
let ctx = opentelemetry::Context::new();
// Simulate stale records left from a previous server run
dao.insert_preview(&ctx, "stale/pending.mp4", "pending")
.unwrap();
dao.insert_preview(&ctx, "stale/failed.mp4", "pending")
.unwrap();
dao.update_status(
&ctx,
"stale/failed.mp4",
"failed",
None,
None,
Some("ffmpeg error"),
)
.unwrap();
let preview_dao = make_preview_dao(dao);
let app_state = Data::new(AppState::test_state());
let token = make_token();
let app = actix_web::test::init_service(
App::new()
.service(get_preview_status)
.app_data(app_state)
.app_data(preview_dao),
)
.await;
let req = actix_web::test::TestRequest::post()
.uri("/video/preview/status")
.insert_header(("Authorization", format!("Bearer {}", token)))
.set_json(serde_json::json!({
"paths": ["stale/pending.mp4", "stale/failed.mp4"]
}))
.to_request();
let resp = actix_web::test::call_service(&app, req).await;
assert_eq!(resp.status(), 200);
let body: serde_json::Value = actix_web::test::read_body_json(resp).await;
let previews = body["previews"].as_array().unwrap();
assert_eq!(previews.len(), 2);
// Both records are returned with their current status
assert_eq!(previews[0]["path"], "stale/pending.mp4");
assert_eq!(previews[0]["status"], "pending");
assert!(previews[0].get("preview_url").is_none());
assert_eq!(previews[1]["path"], "stale/failed.mp4");
assert_eq!(previews[1]["status"], "failed");
assert!(previews[1].get("preview_url").is_none());
}
}

View File

@@ -4,7 +4,10 @@ use crate::database::{
SqliteCalendarEventDao, SqliteDailySummaryDao, SqliteExifDao, SqliteInsightDao, SqliteCalendarEventDao, SqliteDailySummaryDao, SqliteExifDao, SqliteInsightDao,
SqliteLocationHistoryDao, SqliteSearchHistoryDao, SqliteLocationHistoryDao, SqliteSearchHistoryDao,
}; };
use crate::video::actors::{PlaylistGenerator, StreamActor, VideoPlaylistManager}; use crate::database::{PreviewDao, SqlitePreviewDao};
use crate::video::actors::{
PlaylistGenerator, PreviewClipGenerator, StreamActor, VideoPlaylistManager,
};
use actix::{Actor, Addr}; use actix::{Actor, Addr};
use std::env; use std::env;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
@@ -12,10 +15,12 @@ use std::sync::{Arc, Mutex};
pub struct AppState { pub struct AppState {
pub stream_manager: Arc<Addr<StreamActor>>, pub stream_manager: Arc<Addr<StreamActor>>,
pub playlist_manager: Arc<Addr<VideoPlaylistManager>>, pub playlist_manager: Arc<Addr<VideoPlaylistManager>>,
pub preview_clip_generator: Arc<Addr<PreviewClipGenerator>>,
pub base_path: String, pub base_path: String,
pub thumbnail_path: String, pub thumbnail_path: String,
pub video_path: String, pub video_path: String,
pub gif_path: String, pub gif_path: String,
pub preview_clips_path: String,
pub excluded_dirs: Vec<String>, pub excluded_dirs: Vec<String>,
pub ollama: OllamaClient, pub ollama: OllamaClient,
pub sms_client: SmsApiClient, pub sms_client: SmsApiClient,
@@ -29,22 +34,32 @@ impl AppState {
thumbnail_path: String, thumbnail_path: String,
video_path: String, video_path: String,
gif_path: String, gif_path: String,
preview_clips_path: String,
excluded_dirs: Vec<String>, excluded_dirs: Vec<String>,
ollama: OllamaClient, ollama: OllamaClient,
sms_client: SmsApiClient, sms_client: SmsApiClient,
insight_generator: InsightGenerator, insight_generator: InsightGenerator,
preview_dao: Arc<Mutex<Box<dyn PreviewDao>>>,
) -> Self { ) -> Self {
let playlist_generator = PlaylistGenerator::new(); let playlist_generator = PlaylistGenerator::new();
let video_playlist_manager = let video_playlist_manager =
VideoPlaylistManager::new(video_path.clone(), playlist_generator.start()); VideoPlaylistManager::new(video_path.clone(), playlist_generator.start());
let preview_clip_generator = PreviewClipGenerator::new(
preview_clips_path.clone(),
base_path.clone(),
preview_dao,
);
Self { Self {
stream_manager, stream_manager,
playlist_manager: Arc::new(video_playlist_manager.start()), playlist_manager: Arc::new(video_playlist_manager.start()),
preview_clip_generator: Arc::new(preview_clip_generator.start()),
base_path, base_path,
thumbnail_path, thumbnail_path,
video_path, video_path,
gif_path, gif_path,
preview_clips_path,
excluded_dirs, excluded_dirs,
ollama, ollama,
sms_client, sms_client,
@@ -94,6 +109,8 @@ impl Default for AppState {
Arc::new(Mutex::new(Box::new(SqliteExifDao::new()))); Arc::new(Mutex::new(Box::new(SqliteExifDao::new())));
let daily_summary_dao: Arc<Mutex<Box<dyn DailySummaryDao>>> = let daily_summary_dao: Arc<Mutex<Box<dyn DailySummaryDao>>> =
Arc::new(Mutex::new(Box::new(SqliteDailySummaryDao::new()))); Arc::new(Mutex::new(Box::new(SqliteDailySummaryDao::new())));
let preview_dao: Arc<Mutex<Box<dyn PreviewDao>>> =
Arc::new(Mutex::new(Box::new(SqlitePreviewDao::new())));
// Initialize Google Takeout DAOs // Initialize Google Takeout DAOs
let calendar_dao: Arc<Mutex<Box<dyn CalendarEventDao>>> = let calendar_dao: Arc<Mutex<Box<dyn CalendarEventDao>>> =
@@ -119,16 +136,23 @@ impl Default for AppState {
base_path.clone(), base_path.clone(),
); );
// Ensure preview clips directory exists
let preview_clips_path = env::var("PREVIEW_CLIPS_DIRECTORY")
.unwrap_or_else(|_| "preview_clips".to_string());
std::fs::create_dir_all(&preview_clips_path).expect("Failed to create PREVIEW_CLIPS_DIRECTORY");
Self::new( Self::new(
Arc::new(StreamActor {}.start()), Arc::new(StreamActor {}.start()),
base_path, base_path,
env::var("THUMBNAILS").expect("THUMBNAILS was not set in the env"), env::var("THUMBNAILS").expect("THUMBNAILS was not set in the env"),
env::var("VIDEO_PATH").expect("VIDEO_PATH was not set in the env"), env::var("VIDEO_PATH").expect("VIDEO_PATH was not set in the env"),
env::var("GIFS_DIRECTORY").expect("GIFS_DIRECTORY was not set in the env"), env::var("GIFS_DIRECTORY").expect("GIFS_DIRECTORY was not set in the env"),
preview_clips_path,
Self::parse_excluded_dirs(), Self::parse_excluded_dirs(),
ollama, ollama,
sms_client, sms_client,
insight_generator, insight_generator,
preview_dao,
) )
} }
} }
@@ -142,10 +166,11 @@ impl AppState {
let temp_dir = tempfile::tempdir().expect("Failed to create temp directory"); let temp_dir = tempfile::tempdir().expect("Failed to create temp directory");
let base_path = temp_dir.path().to_path_buf(); let base_path = temp_dir.path().to_path_buf();
// Create subdirectories for thumbnails, videos, and gifs // Create subdirectories for thumbnails, videos, gifs, and preview clips
let thumbnail_path = create_test_subdir(&base_path, "thumbnails"); let thumbnail_path = create_test_subdir(&base_path, "thumbnails");
let video_path = create_test_subdir(&base_path, "videos"); let video_path = create_test_subdir(&base_path, "videos");
let gif_path = create_test_subdir(&base_path, "gifs"); let gif_path = create_test_subdir(&base_path, "gifs");
let preview_clips_path = create_test_subdir(&base_path, "preview_clips");
// Initialize test AI clients // Initialize test AI clients
let ollama = OllamaClient::new( let ollama = OllamaClient::new(
@@ -186,6 +211,10 @@ impl AppState {
base_path_str.clone(), base_path_str.clone(),
); );
// Initialize test preview DAO
let preview_dao: Arc<Mutex<Box<dyn PreviewDao>>> =
Arc::new(Mutex::new(Box::new(SqlitePreviewDao::new())));
// Create the AppState with the temporary paths // Create the AppState with the temporary paths
AppState::new( AppState::new(
Arc::new(StreamActor {}.start()), Arc::new(StreamActor {}.start()),
@@ -193,10 +222,12 @@ impl AppState {
thumbnail_path.to_string_lossy().to_string(), thumbnail_path.to_string_lossy().to_string(),
video_path.to_string_lossy().to_string(), video_path.to_string_lossy().to_string(),
gif_path.to_string_lossy().to_string(), gif_path.to_string_lossy().to_string(),
preview_clips_path.to_string_lossy().to_string(),
Vec::new(), // No excluded directories for test state Vec::new(), // No excluded directories for test state
ollama, ollama,
sms_client, sms_client,
insight_generator, insight_generator,
preview_dao,
) )
} }
} }

View File

@@ -3,9 +3,12 @@ use actix_web::{
body::{BoxBody, MessageBody}, body::{BoxBody, MessageBody},
}; };
use crate::database::{UserDao, models::User}; use crate::database::models::{User, VideoPreviewClip};
use crate::database::{DbError, DbErrorKind, PreviewDao, UserDao};
use std::cell::RefCell; use std::cell::RefCell;
use std::collections::HashMap;
use std::option::Option; use std::option::Option;
use std::sync::Mutex as StdMutex;
pub struct TestUserDao { pub struct TestUserDao {
pub user_map: RefCell<Vec<User>>, pub user_map: RefCell<Vec<User>>,
@@ -62,3 +65,106 @@ impl BodyReader for HttpResponse<BoxBody> {
std::str::from_utf8(&body).unwrap().to_string() std::str::from_utf8(&body).unwrap().to_string()
} }
} }
pub struct TestPreviewDao {
pub clips: StdMutex<HashMap<String, VideoPreviewClip>>,
next_id: StdMutex<i32>,
}
impl TestPreviewDao {
pub fn new() -> Self {
Self {
clips: StdMutex::new(HashMap::new()),
next_id: StdMutex::new(1),
}
}
}
impl PreviewDao for TestPreviewDao {
fn insert_preview(
&mut self,
_context: &opentelemetry::Context,
file_path_val: &str,
status_val: &str,
) -> Result<(), DbError> {
let mut clips = self.clips.lock().unwrap();
// insert_or_ignore semantics: skip if key already exists
if clips.contains_key(file_path_val) {
return Ok(());
}
let mut id = self.next_id.lock().unwrap();
let now = chrono::Utc::now().to_rfc3339();
clips.insert(
file_path_val.to_string(),
VideoPreviewClip {
id: *id,
file_path: file_path_val.to_string(),
status: status_val.to_string(),
duration_seconds: None,
file_size_bytes: None,
error_message: None,
created_at: now.clone(),
updated_at: now,
},
);
*id += 1;
Ok(())
}
fn update_status(
&mut self,
_context: &opentelemetry::Context,
file_path_val: &str,
status_val: &str,
duration: Option<f32>,
size: Option<i32>,
error: Option<&str>,
) -> Result<(), DbError> {
let mut clips = self.clips.lock().unwrap();
if let Some(clip) = clips.get_mut(file_path_val) {
clip.status = status_val.to_string();
clip.duration_seconds = duration;
clip.file_size_bytes = size;
clip.error_message = error.map(|s| s.to_string());
clip.updated_at = chrono::Utc::now().to_rfc3339();
Ok(())
} else {
Err(DbError {
kind: DbErrorKind::UpdateError,
})
}
}
fn get_preview(
&mut self,
_context: &opentelemetry::Context,
file_path_val: &str,
) -> Result<Option<VideoPreviewClip>, DbError> {
Ok(self.clips.lock().unwrap().get(file_path_val).cloned())
}
fn get_previews_batch(
&mut self,
_context: &opentelemetry::Context,
file_paths: &[String],
) -> Result<Vec<VideoPreviewClip>, DbError> {
let clips = self.clips.lock().unwrap();
Ok(file_paths
.iter()
.filter_map(|p| clips.get(p).cloned())
.collect())
}
fn get_by_status(
&mut self,
_context: &opentelemetry::Context,
status_val: &str,
) -> Result<Vec<VideoPreviewClip>, DbError> {
let clips = self.clips.lock().unwrap();
Ok(clips
.values()
.filter(|c| c.status == status_val)
.cloned()
.collect())
}
}

View File

@@ -1,5 +1,7 @@
use crate::database::PreviewDao;
use crate::is_video; use crate::is_video;
use crate::otel::global_tracer; use crate::otel::global_tracer;
use crate::video::ffmpeg::generate_preview_clip;
use actix::prelude::*; use actix::prelude::*;
use futures::TryFutureExt; use futures::TryFutureExt;
use log::{debug, error, info, trace, warn}; use log::{debug, error, info, trace, warn};
@@ -8,7 +10,7 @@ use opentelemetry::trace::{Span, Status, Tracer};
use std::io::Result; use std::io::Result;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::{Child, Command, ExitStatus, Stdio}; use std::process::{Child, Command, ExitStatus, Stdio};
use std::sync::Arc; use std::sync::{Arc, Mutex};
use tokio::sync::Semaphore; use tokio::sync::Semaphore;
use walkdir::{DirEntry, WalkDir}; use walkdir::{DirEntry, WalkDir};
// ffmpeg -i test.mp4 -c:v h264 -flags +cgop -g 30 -hls_time 3 out.m3u8 // ffmpeg -i test.mp4 -c:v h264 -flags +cgop -g 30 -hls_time 3 out.m3u8
@@ -484,3 +486,118 @@ impl Handler<GeneratePlaylistMessage> for PlaylistGenerator {
}) })
} }
} }
#[derive(Message)]
#[rtype(result = "()")]
pub struct GeneratePreviewClipMessage {
pub video_path: String,
}
pub struct PreviewClipGenerator {
semaphore: Arc<Semaphore>,
preview_clips_dir: String,
base_path: String,
preview_dao: Arc<Mutex<Box<dyn PreviewDao>>>,
}
impl PreviewClipGenerator {
pub fn new(
preview_clips_dir: String,
base_path: String,
preview_dao: Arc<Mutex<Box<dyn PreviewDao>>>,
) -> Self {
PreviewClipGenerator {
semaphore: Arc::new(Semaphore::new(2)),
preview_clips_dir,
base_path,
preview_dao,
}
}
}
impl Actor for PreviewClipGenerator {
type Context = Context<Self>;
}
impl Handler<GeneratePreviewClipMessage> for PreviewClipGenerator {
type Result = ResponseFuture<()>;
fn handle(
&mut self,
msg: GeneratePreviewClipMessage,
_ctx: &mut Self::Context,
) -> Self::Result {
let semaphore = self.semaphore.clone();
let preview_clips_dir = self.preview_clips_dir.clone();
let base_path = self.base_path.clone();
let preview_dao = self.preview_dao.clone();
let video_path = msg.video_path;
Box::pin(async move {
let permit = semaphore
.acquire_owned()
.await
.expect("Unable to acquire preview semaphore");
// Compute relative path (from BASE_PATH) for DB operations, consistent with EXIF convention
let relative_path = video_path
.strip_prefix(&base_path)
.unwrap_or(&video_path)
.trim_start_matches(['/', '\\'])
.to_string();
// Update status to processing
{
let otel_ctx = opentelemetry::Context::current();
let mut dao = preview_dao.lock().expect("Unable to lock PreviewDao");
let _ = dao.update_status(&otel_ctx, &relative_path, "processing", None, None, None);
}
// Compute output path: join preview_clips_dir with relative path, change ext to .mp4
let output_path = PathBuf::from(&preview_clips_dir)
.join(&relative_path)
.with_extension("mp4");
let output_str = output_path.to_string_lossy().to_string();
let video_path_owned = video_path.clone();
let relative_path_owned = relative_path.clone();
tokio::spawn(async move {
match generate_preview_clip(&video_path_owned, &output_str).await {
Ok((duration, size)) => {
info!(
"Preview clip complete for '{}' ({:.1}s, {} bytes)",
relative_path_owned, duration, size
);
let otel_ctx = opentelemetry::Context::current();
let mut dao = preview_dao.lock().expect("Unable to lock PreviewDao");
let _ = dao.update_status(
&otel_ctx,
&relative_path_owned,
"complete",
Some(duration as f32),
Some(size as i32),
None,
);
}
Err(e) => {
error!(
"Failed to generate preview clip for '{}': {}",
relative_path_owned, e
);
let otel_ctx = opentelemetry::Context::current();
let mut dao = preview_dao.lock().expect("Unable to lock PreviewDao");
let _ = dao.update_status(
&otel_ctx,
&relative_path_owned,
"failed",
None,
None,
Some(&e.to_string()),
);
}
}
drop(permit);
});
})
}
}

View File

@@ -2,9 +2,40 @@ use futures::TryFutureExt;
use log::{debug, error, info, warn}; use log::{debug, error, info, warn};
use std::io::Result; use std::io::Result;
use std::process::{Output, Stdio}; use std::process::{Output, Stdio};
use std::sync::OnceLock;
use std::time::Instant; use std::time::Instant;
use tokio::process::Command; use tokio::process::Command;
static NVENC_AVAILABLE: OnceLock<bool> = OnceLock::new();
/// Check if NVIDIA NVENC hardware encoder is available via ffmpeg.
async fn check_nvenc_available() -> bool {
Command::new("ffmpeg")
.args(["-hide_banner", "-encoders"])
.output()
.await
.map(|out| {
let stdout = String::from_utf8_lossy(&out.stdout);
stdout.contains("h264_nvenc")
})
.unwrap_or(false)
}
/// Returns whether NVENC is available, caching the result after first check.
async fn is_nvenc_available() -> bool {
if let Some(&available) = NVENC_AVAILABLE.get() {
return available;
}
let available = check_nvenc_available().await;
let _ = NVENC_AVAILABLE.set(available);
if available {
info!("CUDA NVENC hardware acceleration detected and enabled for preview clips");
} else {
info!("NVENC not available, using CPU encoding for preview clips");
}
available
}
pub struct Ffmpeg; pub struct Ffmpeg;
pub enum GifType { pub enum GifType {
@@ -152,7 +183,7 @@ impl Ffmpeg {
Ok(output_file.to_string()) Ok(output_file.to_string())
} }
async fn create_gif_from_frames(&self, frame_base_dir: &str, output_file: &str) -> Result<i32> { pub async fn create_gif_from_frames(&self, frame_base_dir: &str, output_file: &str) -> Result<i32> {
let output = Command::new("ffmpeg") let output = Command::new("ffmpeg")
.arg("-y") .arg("-y")
.args(["-framerate", "4"]) .args(["-framerate", "4"])
@@ -183,3 +214,120 @@ impl Ffmpeg {
Ok(output.status.code().unwrap_or(-1)) Ok(output.status.code().unwrap_or(-1))
} }
} }
/// Get video duration in seconds as f64 for precise interval calculation.
async fn get_duration_seconds(input_file: &str) -> Result<f64> {
Command::new("ffprobe")
.args(["-i", input_file])
.args(["-show_entries", "format=duration"])
.args(["-v", "quiet"])
.args(["-of", "csv=p=0"])
.output()
.await
.map(|out| String::from_utf8_lossy(&out.stdout).trim().to_string())
.and_then(|duration_str| {
duration_str
.parse::<f64>()
.map_err(|e| std::io::Error::other(e.to_string()))
})
}
/// Generate a preview clip from a video file.
///
/// Creates a ~10 second MP4 by extracting up to 10 equally-spaced 1-second segments
/// at 480p with H.264 video and AAC audio. For short videos (<10s), uses fewer segments.
/// For very short videos (<1s), transcodes the entire video.
///
/// Returns (duration_seconds, file_size_bytes) on success.
pub async fn generate_preview_clip(input_file: &str, output_file: &str) -> Result<(f64, u64)> {
info!("Generating preview clip for: '{}'", input_file);
let start = Instant::now();
let duration = get_duration_seconds(input_file).await?;
let use_nvenc = is_nvenc_available().await;
// Create parent directories for output
if let Some(parent) = std::path::Path::new(output_file).parent() {
std::fs::create_dir_all(parent)?;
}
let mut cmd = Command::new("ffmpeg");
cmd.arg("-y");
// Use CUDA hardware-accelerated decoding when available
if use_nvenc {
cmd.args(["-hwaccel", "cuda"]);
}
cmd.arg("-i").arg(input_file);
if duration < 1.0 {
// Very short video (<1s): transcode the whole thing to 480p MP4
// format=yuv420p ensures 10-bit sources are converted to 8-bit for h264_nvenc
cmd.args(["-vf", "scale=-2:480,format=yuv420p"]);
} else {
let segment_count = if duration < 10.0 {
duration.floor() as u32
} else {
10
};
let interval = duration / segment_count as f64;
// format=yuv420p ensures 10-bit sources are converted to 8-bit for h264_nvenc
let vf = format!(
"select='lt(mod(t,{:.4}),1)',setpts=N/FRAME_RATE/TB,fps=30,scale=-2:480,format=yuv420p",
interval
);
let af = format!(
"aselect='lt(mod(t,{:.4}),1)',asetpts=N/SR/TB",
interval
);
cmd.args(["-vf", &vf]);
cmd.args(["-af", &af]);
}
// Force 30fps output so high-framerate sources (60fps) don't play back
// at double speed due to select/setpts timestamp mismatches.
cmd.args(["-r", "30"]);
// Use NVENC for encoding when available, otherwise fall back to libx264
if use_nvenc {
cmd.args(["-c:v", "h264_nvenc", "-preset", "p4", "-cq:v", "28"]);
} else {
cmd.args(["-c:v", "libx264", "-crf", "28", "-preset", "veryfast"]);
}
cmd.args(["-c:a", "aac"]);
cmd.arg(output_file);
cmd.stdout(Stdio::null());
cmd.stderr(Stdio::piped());
let output = cmd.output().await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(std::io::Error::other(format!(
"ffmpeg preview generation failed: {}",
stderr
)));
}
let metadata = std::fs::metadata(output_file)?;
let file_size = metadata.len();
let clip_duration = if duration < 1.0 {
duration
} else if duration < 10.0 {
duration.floor()
} else {
10.0
};
info!(
"Generated preview clip '{}' ({:.1}s, {} bytes) in {:?}",
output_file, clip_duration, file_size, start.elapsed()
);
Ok((clip_duration, file_size))
}