Skip to content

Commit 9868129

Browse files
authored
chore: sync dev → main (Media Library v0.9.0)
2 parents dd2a201 + 9c4bd99 commit 9868129

50 files changed

Lines changed: 5383 additions & 107 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env.example

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,12 @@ AI_COST_MONTHLY_LIMIT=500
8383
AI_AUTO_PUBLISH_SCORE=80
8484
AI_HUMAN_GATE_TIMEOUT=48
8585
AI_CONTENT_REFRESH_DAYS=30
86+
87+
# ── Media Library & DAM ──────────────────────────
88+
# MEDIA_AI_TAGGING: Enable automatic AI-based image tagging (default: false)
89+
# Uses Claude vision to analyze images and suggest tags.
90+
MEDIA_AI_TAGGING=false
91+
92+
# CDN_ENABLED: Enable public API endpoints for CDN delivery (default: true)
93+
# Provides /v1/public/media/* endpoints for headless frontends
94+
CDN_ENABLED=true

CHANGELOG.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,55 @@ One-click content repurposing to 8 formats with AI-powered tone preservation and
4242
---
4343
## [Unreleased]
4444

45+
## [0.8.0] — 2026-03-15
46+
47+
### Added
48+
49+
**Media Library & Digital Asset Management** ([Discussion #4](https://github.com/byte5digital/numen/discussions/4))
50+
51+
A complete digital asset management (DAM) system for organizing, tagging, editing, and serving media assets. Built for multi-format content delivery and CDN integration.
52+
53+
**Features:**
54+
55+
- **Folders & Collections** — Organize assets hierarchically using adjacency-list folders. Create smart collections with powerful filtering and bulk operations.
56+
- **Drag-and-drop Upload** — Metadata extraction (MIME type, dimensions, file size, duration) on ingest. Progress tracking and batch upload support.
57+
- **AI Auto-tagging (opt-in)** — Enable `MEDIA_AI_TAGGING` environment variable to automatically tag images using Claude vision. Powered by Anthropic API; all costs logged to `AIGenerationLog`.
58+
- **Image Editing** — Crop, rotate, and resize images via `MediaEditController`. Changes create new variants; originals are preserved.
59+
- **Automatic Variant Generation** — On upload, generate `thumb` (150×150), `medium` (600×600), and `large` (1600×1600) variants. WebP format with configurable quality. Stored locally or on S3 (via `FILESYSTEM_DISK`).
60+
- **Usage Tracking** — Query which content items reference a specific asset. Prevents accidental deletion of in-use media.
61+
- **Public Headless API**`/v1/public/media` endpoints (no auth required) with throttle protection (120 req/min). Perfect for headless frontends and CDN edge caching.
62+
- **Full REST API** — Complete CRUD operations on assets, folders, and collections. Bearer token auth via Sanctum.
63+
- **MediaPicker Vue Component** — Integrates with content editor for seamless asset selection during content creation.
64+
65+
**Environment Variables (new):**
66+
67+
- `MEDIA_AI_TAGGING` — Enable automatic AI-based image tagging (default: `false`)
68+
- `CDN_ENABLED` — Enable public CDN delivery endpoints (default: `true`)
69+
70+
**API Endpoints:**
71+
72+
*Authenticated (requires Bearer token):*
73+
- `GET /v1/media` — List all assets
74+
- `POST /v1/media` — Upload asset (20 req/min throttle)
75+
- `GET /v1/media/{asset}` — Fetch asset details
76+
- `PATCH /v1/media/{asset}` — Update asset metadata
77+
- `DELETE /v1/media/{asset}` — Delete asset
78+
- `PATCH /v1/media/{asset}/move` — Move to folder
79+
- `GET /v1/media/{asset}/usage` — Show usage in content
80+
- `POST /v1/media/{asset}/edit` — Edit (crop/rotate/resize)
81+
- `GET /v1/media/{asset}/variants` — List generated variants
82+
- `GET|POST /v1/media/folders` — CRUD folders
83+
- `PATCH /v1/media/folders/{folder}/move` — Move folder
84+
- `GET|POST|PATCH|DELETE /v1/media/collections` — CRUD collections
85+
- `POST|DELETE /v1/media/collections/{collection}/items` — Manage collection items
86+
87+
*Public (no auth):*
88+
- `GET /v1/public/media` — List public assets (120 req/min throttle)
89+
- `GET /v1/public/media/{asset}` — Fetch public asset
90+
- `GET /v1/public/media/collections/{collection}` — Fetch collection
91+
92+
---
93+
4594
### Planned
4695
- Remove legacy `numen.anthropic` config block (duplicates `numen.providers.anthropic`)
4796
- `AgentContract` interface extracted from `Agent` abstract class

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ Role-based access control (Admin, Editor, Author, Viewer) with space-scoped perm
9797

9898
- **Provider abstraction** — swap Anthropic ↔ OpenAI ↔ Azure without touching pipeline code. Fallback chain auto-retries on rate limits.
9999
- **Multi-provider image generation** — Four image providers supported: OpenAI (GPT Image 1.5 / DALL-E 3), Together AI (FLUX models), fal.ai (FLUX / SD3.5 / Recraft), and Replicate (any model). An `ImagePromptBuilder` (powered by Haiku) crafts optimized prompts from content metadata; images are downloaded, stored as `MediaAsset` records, and attached to content. The active provider is configured per-persona via `generator_provider` / `generator_model`.
100+
- **Media Library & Digital Asset Management** — Organize media assets into folders and collections with drag-and-drop upload. Automatic metadata extraction (MIME, dimensions, file size), optional AI auto-tagging via Claude vision, and image editing (crop/rotate/resize). Automatic variant generation (thumbnail, medium, large) for web delivery. Public headless API (`/v1/public/media`) for CDN edge caching; full REST API with Sanctum auth. MediaPicker Vue component for seamless integration with content editor. Usage tracking prevents accidental deletion of in-use assets.
100101
- **RBAC with AI governance** — role-based access control (Admin, Editor, Author, Viewer) with space-scoped permissions, AI budget limits per role, and immutable audit logs. Tokens inherit a subset of user permissions. No external dependencies — Numen's own implementation.
101102
- **Pipeline-as-config** — stages defined in DB, not hardcoded. Add/remove/reorder stages without deploying code. Supports `human_gate` stages for manual review checkpoints.
102103
- **Block-based content** — every piece of content is a collection of typed `ContentBlock` records. Flexible for headless delivery.
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
<?php
2+
3+
namespace App\Http\Controllers\Api;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Models\MediaAsset;
7+
use App\Services\AuthorizationService;
8+
use Illuminate\Http\JsonResponse;
9+
use Illuminate\Http\Request;
10+
use Illuminate\Support\Facades\DB;
11+
use Illuminate\Support\Str;
12+
13+
class MediaCollectionController extends Controller
14+
{
15+
public function __construct(private readonly AuthorizationService $authz) {}
16+
17+
/**
18+
* List all collections for a space.
19+
* GET /v1/media/collections?space_id=...
20+
*/
21+
public function index(Request $request): JsonResponse
22+
{
23+
$request->validate([
24+
'space_id' => ['required', 'ulid', 'exists:spaces,id'],
25+
]);
26+
27+
$spaceId = $request->input('space_id');
28+
$this->authz->authorize($request->user(), 'media.read', $spaceId);
29+
30+
$collections = DB::table('media_collections')
31+
->where('space_id', $spaceId)
32+
->orderBy('name')
33+
->get()
34+
->map(function ($col) {
35+
$col->items_count = DB::table('media_collection_items')
36+
->where('collection_id', $col->id)
37+
->count();
38+
39+
return $col;
40+
});
41+
42+
return response()->json(['data' => $collections]);
43+
}
44+
45+
/**
46+
* Create a new collection.
47+
* POST /v1/media/collections
48+
*/
49+
public function store(Request $request): JsonResponse
50+
{
51+
$data = $request->validate([
52+
'space_id' => ['required', 'ulid', 'exists:spaces,id'],
53+
'name' => ['required', 'string', 'max:255'],
54+
'description' => ['nullable', 'string', 'max:1000'],
55+
'is_smart' => ['boolean'],
56+
'criteria' => ['nullable', 'array'],
57+
]);
58+
59+
$this->authz->authorize($request->user(), 'media.update', $data['space_id']);
60+
61+
$id = DB::table('media_collections')->insertGetId([
62+
'space_id' => $data['space_id'],
63+
'name' => $data['name'],
64+
'slug' => Str::slug($data['name']),
65+
'description' => $data['description'] ?? null,
66+
'is_smart' => $data['is_smart'] ?? false,
67+
'criteria' => isset($data['criteria']) ? json_encode($data['criteria']) : null,
68+
'created_at' => now(),
69+
'updated_at' => now(),
70+
]);
71+
72+
$collection = DB::table('media_collections')->find($id);
73+
74+
return response()->json(['data' => $collection], 201);
75+
}
76+
77+
/**
78+
* Get a single collection with its items.
79+
* GET /v1/media/collections/{collection}
80+
*/
81+
public function show(Request $request, int $collection): JsonResponse
82+
{
83+
$col = DB::table('media_collections')->where('id', $collection)->first();
84+
abort_unless($col !== null, 404);
85+
86+
$this->authz->authorize($request->user(), 'media.read', $col->space_id);
87+
88+
$items = MediaAsset::join('media_collection_items', 'media_assets.id', '=', 'media_collection_items.media_asset_id')
89+
->where('media_collection_items.collection_id', $collection)
90+
->orderBy('media_collection_items.sort_order')
91+
->select('media_assets.*', 'media_collection_items.sort_order', 'media_collection_items.added_at')
92+
->get()
93+
->map(fn ($a) => array_merge($a->toArray(), ['url' => $a->url]));
94+
95+
return response()->json([
96+
'data' => $col,
97+
'items' => $items,
98+
]);
99+
}
100+
101+
/**
102+
* Rename / update a collection.
103+
* PATCH /v1/media/collections/{collection}
104+
*/
105+
public function update(Request $request, int $collection): JsonResponse
106+
{
107+
$col = DB::table('media_collections')->where('id', $collection)->first();
108+
abort_unless($col !== null, 404);
109+
110+
$this->authz->authorize($request->user(), 'media.update', $col->space_id);
111+
112+
$data = $request->validate([
113+
'name' => ['sometimes', 'string', 'max:255'],
114+
'description' => ['nullable', 'string', 'max:1000'],
115+
'criteria' => ['nullable', 'array'],
116+
]);
117+
118+
$patch = ['updated_at' => now()];
119+
if (isset($data['name'])) {
120+
$patch['name'] = $data['name'];
121+
$patch['slug'] = Str::slug($data['name']);
122+
}
123+
if (array_key_exists('description', $data)) {
124+
$patch['description'] = $data['description'];
125+
}
126+
if (array_key_exists('criteria', $data)) {
127+
$patch['criteria'] = json_encode($data['criteria']);
128+
}
129+
130+
DB::table('media_collections')->where('id', $collection)->update($patch);
131+
132+
return response()->json(['data' => DB::table('media_collections')->find($collection)]);
133+
}
134+
135+
/**
136+
* Delete a collection.
137+
* DELETE /v1/media/collections/{collection}
138+
*/
139+
public function destroy(Request $request, int $collection): JsonResponse
140+
{
141+
$col = DB::table('media_collections')->where('id', $collection)->first();
142+
abort_unless($col !== null, 404);
143+
144+
$this->authz->authorize($request->user(), 'media.delete', $col->space_id);
145+
146+
DB::table('media_collections')->where('id', $collection)->delete();
147+
148+
return response()->json(null, 204);
149+
}
150+
151+
/**
152+
* Add an asset to a collection.
153+
* POST /v1/media/collections/{collection}/items
154+
*/
155+
public function addItem(Request $request, int $collection): JsonResponse
156+
{
157+
$col = DB::table('media_collections')->where('id', $collection)->first();
158+
abort_unless($col !== null, 404);
159+
160+
$this->authz->authorize($request->user(), 'media.update', $col->space_id);
161+
162+
$data = $request->validate([
163+
'media_asset_id' => ['required', 'string', 'exists:media_assets,id'],
164+
'sort_order' => ['integer'],
165+
]);
166+
167+
// Verify asset belongs to the same space as the collection
168+
MediaAsset::where('id', $data['media_asset_id'])
169+
->where('space_id', $col->space_id)
170+
->firstOrFail();
171+
172+
DB::table('media_collection_items')->insertOrIgnore([
173+
'collection_id' => $collection,
174+
'media_asset_id' => $data['media_asset_id'],
175+
'sort_order' => $data['sort_order'] ?? 0,
176+
'added_at' => now(),
177+
]);
178+
179+
return response()->json(['message' => 'Asset added to collection.'], 201);
180+
}
181+
182+
/**
183+
* Remove an asset from a collection.
184+
* DELETE /v1/media/collections/{collection}/items/{asset}
185+
*/
186+
public function removeItem(Request $request, int $collection, string $asset): JsonResponse
187+
{
188+
$col = DB::table('media_collections')->where('id', $collection)->first();
189+
abort_unless($col !== null, 404);
190+
191+
$this->authz->authorize($request->user(), 'media.update', $col->space_id);
192+
193+
DB::table('media_collection_items')
194+
->where('collection_id', $collection)
195+
->where('media_asset_id', $asset)
196+
->delete();
197+
198+
return response()->json(null, 204);
199+
}
200+
}

0 commit comments

Comments
 (0)