Compare commits
5 Commits
ac700787bd
...
81912cac66
| Author | SHA1 | Date | |
|---|---|---|---|
| 81912cac66 | |||
| 2918b7acbf | |||
| 1142c38c62 | |||
| 52be5be93f | |||
| 67060c73b2 |
+10
-2
@@ -671,7 +671,11 @@
|
|||||||
"trimAndCrop": "Trim & Crop",
|
"trimAndCrop": "Trim & Crop",
|
||||||
"loadingEngine": "Loading FFmpeg engine…",
|
"loadingEngine": "Loading FFmpeg engine…",
|
||||||
"progress": "Progress",
|
"progress": "Progress",
|
||||||
"download": "Download {format}"
|
"download": "Download {format}",
|
||||||
|
"saveToCloud": "Save to my account",
|
||||||
|
"savingToCloud": "Saving…",
|
||||||
|
"savedToCloud": "Saved to your account",
|
||||||
|
"saveToCloudFailed": "Could not save"
|
||||||
},
|
},
|
||||||
"componentsTrimmerTrimmerStrip": {
|
"componentsTrimmerTrimmerStrip": {
|
||||||
"heading": "Trim",
|
"heading": "Trim",
|
||||||
@@ -866,7 +870,11 @@
|
|||||||
"quality": "Quality",
|
"quality": "Quality",
|
||||||
"download": "Download",
|
"download": "Download",
|
||||||
"canvasNotReady": "Canvas not ready.",
|
"canvasNotReady": "Canvas not ready.",
|
||||||
"exportStarted": "Export started"
|
"exportStarted": "Export started",
|
||||||
|
"saveToCloud": "Save to my account",
|
||||||
|
"savingToCloud": "Saving…",
|
||||||
|
"savedToCloud": "Saved to your account",
|
||||||
|
"saveToCloudFailed": "Could not save to your account"
|
||||||
},
|
},
|
||||||
"componentsImageEditorPanelsAdjustPanel": {
|
"componentsImageEditorPanelsAdjustPanel": {
|
||||||
"emptyState": "Open an image to use adjustments.",
|
"emptyState": "Open an image to use adjustments.",
|
||||||
|
|||||||
+10
-2
@@ -671,7 +671,11 @@
|
|||||||
"trimAndCrop": "برش و کراپ",
|
"trimAndCrop": "برش و کراپ",
|
||||||
"loadingEngine": "در حال بارگذاری موتور FFmpeg…",
|
"loadingEngine": "در حال بارگذاری موتور FFmpeg…",
|
||||||
"progress": "پیشرفت",
|
"progress": "پیشرفت",
|
||||||
"download": "دانلود {format}"
|
"download": "دانلود {format}",
|
||||||
|
"saveToCloud": "ذخیره در حساب",
|
||||||
|
"savingToCloud": "در حال ذخیره…",
|
||||||
|
"savedToCloud": "در حساب شما ذخیره شد",
|
||||||
|
"saveToCloudFailed": "ذخیره ناموفق بود"
|
||||||
},
|
},
|
||||||
"componentsTrimmerTrimmerStrip": {
|
"componentsTrimmerTrimmerStrip": {
|
||||||
"heading": "برش",
|
"heading": "برش",
|
||||||
@@ -866,7 +870,11 @@
|
|||||||
"quality": "کیفیت",
|
"quality": "کیفیت",
|
||||||
"download": "دانلود",
|
"download": "دانلود",
|
||||||
"canvasNotReady": "بوم آماده نیست.",
|
"canvasNotReady": "بوم آماده نیست.",
|
||||||
"exportStarted": "خروجیگیری آغاز شد"
|
"exportStarted": "خروجیگیری آغاز شد",
|
||||||
|
"saveToCloud": "ذخیره در حساب",
|
||||||
|
"savingToCloud": "در حال ذخیره…",
|
||||||
|
"savedToCloud": "در حساب شما ذخیره شد",
|
||||||
|
"saveToCloudFailed": "ذخیره در حساب ناموفق بود"
|
||||||
},
|
},
|
||||||
"componentsImageEditorPanelsAdjustPanel": {
|
"componentsImageEditorPanelsAdjustPanel": {
|
||||||
"emptyState": "برای استفاده از تنظیمات، یک تصویر باز کنید.",
|
"emptyState": "برای استفاده از تنظیمات، یک تصویر باز کنید.",
|
||||||
|
|||||||
@@ -182,6 +182,19 @@ public class AdminService(IdentityDbContext db)
|
|||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Set how many renders a user may run concurrently. The render service reads
|
||||||
|
/// this from the JWT (max_renders claim); takes effect on the user's next token
|
||||||
|
/// refresh. Clamped to [1, 50].
|
||||||
|
/// </summary>
|
||||||
|
public async Task SetRenderSlotsAsync(Guid userId, int ceiling)
|
||||||
|
{
|
||||||
|
var u = await RequireUser(userId);
|
||||||
|
u.ParallelRenderingCeiling = Math.Clamp(ceiling, 1, 50);
|
||||||
|
u.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
public async Task GrantPlanDaysAsync(Guid userId, Guid planId, int days)
|
public async Task GrantPlanDaysAsync(Guid userId, Guid planId, int days)
|
||||||
{
|
{
|
||||||
var plan = await db.UserPlans
|
var plan = await db.UserPlans
|
||||||
|
|||||||
@@ -85,6 +85,52 @@ public class DiscountService(IdentityDbContext db) : IDiscountService
|
|||||||
return MapResponse(discount);
|
return MapResponse(discount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<DiscountResponse?> UpdateAsync(Guid tenantId, Guid id, UpdateDiscountRequest request)
|
||||||
|
{
|
||||||
|
var discount = await db.Discounts.FirstOrDefaultAsync(d => d.Id == id && d.TenantId == tenantId);
|
||||||
|
if (discount == null) return null;
|
||||||
|
|
||||||
|
if (request.Name != null) discount.Name = request.Name;
|
||||||
|
if (request.Code != null)
|
||||||
|
{
|
||||||
|
var newCode = request.Code.ToUpper();
|
||||||
|
if (newCode != discount.Code)
|
||||||
|
{
|
||||||
|
var clash = await db.Discounts.AnyAsync(d =>
|
||||||
|
d.TenantId == tenantId && d.Code == newCode && d.Id != id);
|
||||||
|
if (clash) throw new InvalidOperationException("Discount code already exists");
|
||||||
|
discount.Code = newCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (request.Kind != null)
|
||||||
|
{
|
||||||
|
if (!Enum.TryParse<DiscountKind>(request.Kind, true, out var kind))
|
||||||
|
throw new ArgumentException("Invalid discount kind");
|
||||||
|
discount.Kind = kind;
|
||||||
|
}
|
||||||
|
if (request.Value.HasValue) discount.Value = request.Value.Value;
|
||||||
|
if (request.OwnerUserId.HasValue) discount.OwnerUserId = request.OwnerUserId;
|
||||||
|
if (request.OwnerProfitPercentage.HasValue) discount.OwnerProfitPercentage = request.OwnerProfitPercentage.Value;
|
||||||
|
if (request.MaxUseCount.HasValue) discount.MaxUseCount = request.MaxUseCount;
|
||||||
|
if (request.AppliesToPlanIds != null) discount.AppliesToPlanIds = request.AppliesToPlanIds;
|
||||||
|
if (request.StartsAt.HasValue) discount.StartsAt = request.StartsAt;
|
||||||
|
if (request.ExpiresAt.HasValue) discount.ExpiresAt = request.ExpiresAt;
|
||||||
|
if (request.IsActive.HasValue) discount.IsActive = request.IsActive.Value;
|
||||||
|
discount.UpdatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return MapResponse(discount);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> DeleteAsync(Guid tenantId, Guid id)
|
||||||
|
{
|
||||||
|
var discount = await db.Discounts.FirstOrDefaultAsync(d => d.Id == id && d.TenantId == tenantId);
|
||||||
|
if (discount == null) return false;
|
||||||
|
db.Discounts.Remove(discount);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private static DiscountResponse MapResponse(Discount d) => new(
|
private static DiscountResponse MapResponse(Discount d) => new(
|
||||||
d.Id, d.Name, d.Code, d.Kind.ToString(), d.Value,
|
d.Id, d.Name, d.Code, d.Kind.ToString(), d.Value,
|
||||||
d.UsedCount, d.MaxUseCount, d.IsActive, d.ExpiresAt, d.CreatedAt
|
d.UsedCount, d.MaxUseCount, d.IsActive, d.ExpiresAt, d.CreatedAt
|
||||||
|
|||||||
+2
@@ -8,4 +8,6 @@ public interface IDiscountService
|
|||||||
Task<DiscountValidateResponse> ValidateAsync(Guid tenantId, string code, Guid? planId);
|
Task<DiscountValidateResponse> ValidateAsync(Guid tenantId, string code, Guid? planId);
|
||||||
Task<PagedResponse<DiscountResponse>> ListAsync(Guid tenantId, int page, int pageSize);
|
Task<PagedResponse<DiscountResponse>> ListAsync(Guid tenantId, int page, int pageSize);
|
||||||
Task<DiscountResponse> CreateAsync(Guid tenantId, CreateDiscountRequest request);
|
Task<DiscountResponse> CreateAsync(Guid tenantId, CreateDiscountRequest request);
|
||||||
|
Task<DiscountResponse?> UpdateAsync(Guid tenantId, Guid id, UpdateDiscountRequest request);
|
||||||
|
Task<bool> DeleteAsync(Guid tenantId, Guid id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ public class TokenService(IConfiguration config) : ITokenService
|
|||||||
new("is_admin", user.IsAdmin.ToString().ToLower()),
|
new("is_admin", user.IsAdmin.ToString().ToLower()),
|
||||||
new("is_tenant_admin", user.IsTenantAdmin.ToString().ToLower()),
|
new("is_tenant_admin", user.IsTenantAdmin.ToString().ToLower()),
|
||||||
new("role", role),
|
new("role", role),
|
||||||
|
// Concurrent-render ceiling — render-svc enforces "active renders < max_renders".
|
||||||
|
// Admin grants or gamification raise ParallelRenderingCeiling; default is 1.
|
||||||
|
new("max_renders", Math.Max(1, user.ParallelRenderingCeiling).ToString()),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(user.Email))
|
if (!string.IsNullOrEmpty(user.Email))
|
||||||
|
|||||||
@@ -79,6 +79,14 @@ public class AdminController(AdminService svc) : ControllerBase
|
|||||||
return Ok(new { ok = true });
|
return Ok(new { ok = true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Grant a user extra concurrent render slots (takes effect on next token refresh).
|
||||||
|
[HttpPost("v1/users/{userId:guid}/render-slots")]
|
||||||
|
public async Task<IActionResult> RenderSlots(Guid userId, [FromBody] SetRenderSlotsRequest req)
|
||||||
|
{
|
||||||
|
await svc.SetRenderSlotsAsync(userId, req.Ceiling);
|
||||||
|
return Ok(new { ok = true });
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("v1/users/{userId:guid}/grant-plan")]
|
[HttpPost("v1/users/{userId:guid}/grant-plan")]
|
||||||
public async Task<IActionResult> GrantPlan(Guid userId, [FromBody] GrantPlanDaysRequest req)
|
public async Task<IActionResult> GrantPlan(Guid userId, [FromBody] GrantPlanDaysRequest req)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -37,6 +37,22 @@ public class DiscountsController(IDiscountService discountService) : ControllerB
|
|||||||
return StatusCode(201, result);
|
return StatusCode(201, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:guid}")]
|
||||||
|
[ProducesResponseType(typeof(DiscountResponse), 200)]
|
||||||
|
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateDiscountRequest request)
|
||||||
|
{
|
||||||
|
var result = await discountService.UpdateAsync(GetTenantId(), id, request);
|
||||||
|
return result == null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id:guid}")]
|
||||||
|
[ProducesResponseType(204)]
|
||||||
|
public async Task<IActionResult> Delete(Guid id)
|
||||||
|
{
|
||||||
|
var ok = await discountService.DeleteAsync(GetTenantId(), id);
|
||||||
|
return ok ? NoContent() : NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
private Guid GetTenantId() => Guid.Parse(User.FindFirst("tenant_id")?.Value
|
private Guid GetTenantId() => Guid.Parse(User.FindFirst("tenant_id")?.Value
|
||||||
?? throw new UnauthorizedAccessException());
|
?? throw new UnauthorizedAccessException());
|
||||||
|
|
||||||
|
|||||||
@@ -37,3 +37,4 @@ public record ResetPasswordRequest(string NewPassword);
|
|||||||
public record AddChargeRequest(int Seconds, int RenderCount); // grant render seconds / daily renders
|
public record AddChargeRequest(int Seconds, int RenderCount); // grant render seconds / daily renders
|
||||||
public record GrantPlanDaysRequest(Guid PlanId, int Days);
|
public record GrantPlanDaysRequest(Guid PlanId, int Days);
|
||||||
public record SetFlagRequest(bool Enabled);
|
public record SetFlagRequest(bool Enabled);
|
||||||
|
public record SetRenderSlotsRequest(int Ceiling); // concurrent-render ceiling
|
||||||
|
|||||||
@@ -23,6 +23,21 @@ public record CreateDiscountRequest(
|
|||||||
DateTime? ExpiresAt = null
|
DateTime? ExpiresAt = null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Partial update — every field optional; only non-null values are applied.
|
||||||
|
public record UpdateDiscountRequest(
|
||||||
|
string? Name = null,
|
||||||
|
string? Code = null,
|
||||||
|
string? Kind = null,
|
||||||
|
decimal? Value = null,
|
||||||
|
Guid? OwnerUserId = null,
|
||||||
|
decimal? OwnerProfitPercentage = null,
|
||||||
|
int? MaxUseCount = null,
|
||||||
|
Guid[]? AppliesToPlanIds = null,
|
||||||
|
DateTime? StartsAt = null,
|
||||||
|
DateTime? ExpiresAt = null,
|
||||||
|
bool? IsActive = null
|
||||||
|
);
|
||||||
|
|
||||||
public record IssueRefundRequest(
|
public record IssueRefundRequest(
|
||||||
[Required] Guid PaymentId,
|
[Required] Guid PaymentId,
|
||||||
long? AmountMinor,
|
long? AmountMinor,
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
# FlatRender Node Agent — Deployment
|
||||||
|
|
||||||
|
This folder turns a Windows machine with After Effects into a FlatRender render
|
||||||
|
node: connected to the control plane over an encrypted WireGuard mesh, running
|
||||||
|
the agent as an auto-restarting Windows service.
|
||||||
|
|
||||||
|
```
|
||||||
|
deploy/
|
||||||
|
├── build-windows.ps1 Cross-compile the agent → dist\ (run on your dev box)
|
||||||
|
├── agent.env.example Agent config template → copy to agent.env
|
||||||
|
├── install-service.ps1 Register the agent as a Windows service (sc.exe)
|
||||||
|
├── uninstall-service.ps1 Remove the service
|
||||||
|
├── wireguard-node.conf.template WireGuard client config → fill in → wg-flatrender.conf
|
||||||
|
└── setup-wireguard.ps1 Install + start the WireGuard tunnel as a boot service
|
||||||
|
```
|
||||||
|
|
||||||
|
The node only ever dials **out** to the control plane. It needs **no public IP**
|
||||||
|
and **no inbound firewall rules** — home ADSL, CGNAT, or any datacenter all work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
WireGuard mesh 10.66.0.0/24
|
||||||
|
┌─────────────────────────────┐ ┌──────────────────────────────┐
|
||||||
|
│ Control plane 10.66.0.1 │◄───────►│ Render node 10.66.0.11 │
|
||||||
|
│ ─ gateway :8088 │ encrypted ─ wireguard tunnel (svc) │
|
||||||
|
│ ─ render-svc (orchestrator)│ tunnel │ ─ node-agent (svc) │
|
||||||
|
│ ─ MinIO (templates/exports)│ │ ─ After Effects + aerender │
|
||||||
|
└─────────────────────────────┘ └──────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
The agent: claims a job → downloads the `.aep` bundle from MinIO → binds user
|
||||||
|
customisations (JSX) → renders with `aerender.exe` → uploads the MP4 → reports
|
||||||
|
complete. It heartbeats every 5 s and streams live preview frames while rendering.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Control plane: one-time WireGuard server setup
|
||||||
|
|
||||||
|
On the Linux host that runs the V2 stack (gateway + MinIO):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install
|
||||||
|
sudo apt install -y wireguard
|
||||||
|
|
||||||
|
# Generate the server keypair
|
||||||
|
wg genkey | tee server.key | wg pubkey > server.pub
|
||||||
|
|
||||||
|
# /etc/wireguard/wg0.conf
|
||||||
|
cat >/etc/wireguard/wg0.conf <<'EOF'
|
||||||
|
[Interface]
|
||||||
|
Address = 10.66.0.1/24
|
||||||
|
ListenPort = 51820
|
||||||
|
PrivateKey = <contents of server.key>
|
||||||
|
|
||||||
|
# One [Peer] block per render node (append as you add nodes):
|
||||||
|
# [Peer]
|
||||||
|
# PublicKey = <node-1 public key>
|
||||||
|
# AllowedIPs = 10.66.0.11/32
|
||||||
|
EOF
|
||||||
|
|
||||||
|
sudo systemctl enable --now wg-quick@wg0
|
||||||
|
sudo wg show # prints the server public key for the node config
|
||||||
|
```
|
||||||
|
|
||||||
|
Open UDP **51820** to the internet (the only inbound port the control plane needs
|
||||||
|
for the mesh). The gateway (:8088) and MinIO stay bound to the WG interface — they
|
||||||
|
are never exposed publicly.
|
||||||
|
|
||||||
|
> Each time you add a node, append its `[Peer]` block and `sudo wg syncconf wg0 <(wg-quick strip wg0)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Build the agent (on your dev machine)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Requires Go 1.25+. Produces dist\flatrender-node-agent.exe + the deploy kit.
|
||||||
|
cd services\node-agent\deploy
|
||||||
|
.\build-windows.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
Copy the whole `dist\` folder to each render node (e.g. `C:\flatrender\`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Render node: WireGuard
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# On the node, generate its keypair (WireGuard GUI → Add Tunnel → it shows the keys,
|
||||||
|
# or use the bundled wg.exe): wg genkey | wg pubkey
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Copy `wireguard-node.conf.template` → `wg-flatrender.conf`.
|
||||||
|
2. Fill the four placeholders:
|
||||||
|
- `NODE_PRIVATE_KEY` — this node's private key
|
||||||
|
- `NODE_NUMBER` — unique mesh octet (11, 12, 13, …) → `Address = 10.66.0.11/32`
|
||||||
|
- `SERVER_PUBLIC_KEY` — from `wg show` on the control plane
|
||||||
|
- `SERVER_PUBLIC_ENDPOINT` — the control plane's public IP/host
|
||||||
|
3. Add this node's **public** key + `AllowedIPs = 10.66.0.11/32` as a `[Peer]` on the server (step 1).
|
||||||
|
4. Install the tunnel (elevated PowerShell):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\setup-wireguard.ps1 -ConfigPath .\wg-flatrender.conf
|
||||||
|
ping 10.66.0.1 # should reply over the tunnel
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Render node: agent service
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Configure
|
||||||
|
Copy-Item agent.env.example agent.env
|
||||||
|
notepad agent.env # set NODE_ID, NODE_HMAC_SECRET, ORCHESTRATOR_URL=http://10.66.0.1:8088, AE_PATH
|
||||||
|
```
|
||||||
|
|
||||||
|
Get `NODE_ID` by creating the node in the admin panel (**/admin/nodes → add**), or
|
||||||
|
via `POST /v1/nodes`. `NODE_HMAC_SECRET` must equal the render-svc value in `.env.v2`.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Install + start the service (elevated)
|
||||||
|
.\install-service.ps1
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
curl http://localhost:7777/health
|
||||||
|
Get-Service FlatRenderNodeAgent
|
||||||
|
```
|
||||||
|
|
||||||
|
The node now appears **Ready** in `/admin/nodes` and starts claiming jobs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Operations
|
||||||
|
|
||||||
|
| Task | Command |
|
||||||
|
|---|---|
|
||||||
|
| Health | `curl http://localhost:7777/health` |
|
||||||
|
| Service status | `Get-Service FlatRenderNodeAgent` |
|
||||||
|
| Restart | `Restart-Service FlatRenderNodeAgent` |
|
||||||
|
| Stop | `Stop-Service FlatRenderNodeAgent` |
|
||||||
|
| Update binary | Stop service → replace exe → Start service |
|
||||||
|
| Change config | Edit `agent.env` → `Restart-Service FlatRenderNodeAgent` |
|
||||||
|
| Remove | `.\uninstall-service.ps1` |
|
||||||
|
| Tunnel status | `& 'C:\Program Files\WireGuard\wireguard.exe' show` |
|
||||||
|
|
||||||
|
The service auto-restarts on crash (3× at 5 s intervals) and auto-starts at boot.
|
||||||
|
WireGuard comes up first, so the agent always has a path to the gateway.
|
||||||
|
|
||||||
|
### Mock mode
|
||||||
|
Leave `AE_PATH` empty in `agent.env` to run the **mock renderer** — useful to smoke-test
|
||||||
|
the claim → download → upload → complete pipeline on a node without an AE licence.
|
||||||
|
|
||||||
|
### Troubleshooting
|
||||||
|
- **Node never goes Ready**: tunnel down (`wireguard.exe show`) or wrong `ORCHESTRATOR_URL`.
|
||||||
|
- **401 / signature errors**: `NODE_HMAC_SECRET` mismatch with render-svc.
|
||||||
|
- **Jobs claim but fail at download**: MinIO not reachable over the mesh — confirm MinIO
|
||||||
|
is bound to `10.66.0.1` and the presigned host in render-svc points at the mesh IP.
|
||||||
|
- **AE hangs**: a stale `aerender.exe`/`AfterFX.exe` — the agent force-kills these before
|
||||||
|
each launch; confirm AE opens manually and isn't stuck on a "Crash Repair" dialog.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# FlatRender Node Agent configuration.
|
||||||
|
# Copy to `agent.env` and place next to flatrender-node-agent.exe.
|
||||||
|
# The agent reads this file on startup (env vars still override any line here).
|
||||||
|
|
||||||
|
# ── Required ─────────────────────────────────────────────────────────────────
|
||||||
|
# UUID of this node, pre-created in render.render_nodes (admin → /admin/nodes).
|
||||||
|
NODE_ID=00000000-0000-0000-0000-000000000000
|
||||||
|
|
||||||
|
# ── Connectivity ─────────────────────────────────────────────────────────────
|
||||||
|
# Gateway base URL. Over the WireGuard mesh this is the control-plane's WG IP.
|
||||||
|
# Example (WG): http://10.66.0.1:8088 Local dev: http://localhost:8088
|
||||||
|
ORCHESTRATOR_URL=http://10.66.0.1:8088
|
||||||
|
|
||||||
|
# Shared secret for the X-Node-Signature header. MUST match NODE_HMAC_SECRET
|
||||||
|
# in the render-svc environment (.env.v2). Change from the default!
|
||||||
|
NODE_HMAC_SECRET=change-me-to-a-long-random-secret
|
||||||
|
|
||||||
|
# Region label — the orchestrator routes region-preferred jobs here.
|
||||||
|
NODE_REGION=iran-tehran-1
|
||||||
|
|
||||||
|
# ── After Effects ────────────────────────────────────────────────────────────
|
||||||
|
# Full path to aerender.exe. Leave empty to run the MOCK renderer (no AE needed).
|
||||||
|
AE_PATH=C:\Program Files\Adobe\Adobe After Effects 2024\Support Files\aerender.exe
|
||||||
|
AE_VERSION=2024
|
||||||
|
|
||||||
|
# ── Local paths / tuning ─────────────────────────────────────────────────────
|
||||||
|
# Scratch dir for downloaded .aep bundles + render output. Use a fast NVMe drive.
|
||||||
|
WORK_DIR=C:\flatrender\work
|
||||||
|
|
||||||
|
# Health endpoint port (the orchestrator and you can curl http://<node>:7777/health).
|
||||||
|
LISTEN_PORT=7777
|
||||||
|
|
||||||
|
# Loop cadences (seconds).
|
||||||
|
HEARTBEAT_INTERVAL_SEC=5
|
||||||
|
POLL_INTERVAL_SEC=3
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Cross-compile the FlatRender Node Agent to a Windows .exe.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Produces dist\flatrender-node-agent.exe and stages agent.env.example + the
|
||||||
|
deploy scripts alongside it, ready to copy to a render node.
|
||||||
|
|
||||||
|
Requires Go 1.25+ installed locally (works on Windows, macOS, or Linux).
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\build-windows.ps1
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[string]$OutDir = (Join-Path $PSScriptRoot "dist")
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$agentRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
|
||||||
|
$exe = Join-Path $OutDir "flatrender-node-agent.exe"
|
||||||
|
|
||||||
|
Write-Host "Building Windows binary from $agentRoot ..."
|
||||||
|
$env:GOOS = "windows"
|
||||||
|
$env:GOARCH = "amd64"
|
||||||
|
$env:CGO_ENABLED = "0"
|
||||||
|
Push-Location $agentRoot
|
||||||
|
try {
|
||||||
|
& go build -trimpath -ldflags="-s -w" -o $exe ./cmd/agent
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "go build failed ($LASTEXITCODE)" }
|
||||||
|
} finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
|
|
||||||
|
# Stage the deploy kit next to the exe
|
||||||
|
Copy-Item (Join-Path $PSScriptRoot "agent.env.example") $OutDir -Force
|
||||||
|
Copy-Item (Join-Path $PSScriptRoot "install-service.ps1") $OutDir -Force
|
||||||
|
Copy-Item (Join-Path $PSScriptRoot "uninstall-service.ps1") $OutDir -Force
|
||||||
|
Copy-Item (Join-Path $PSScriptRoot "setup-wireguard.ps1") $OutDir -Force
|
||||||
|
Copy-Item (Join-Path $PSScriptRoot "wireguard-node.conf.template") $OutDir -Force
|
||||||
|
Copy-Item (Join-Path $PSScriptRoot "README.md") $OutDir -Force
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "✓ Built: $exe" -ForegroundColor Green
|
||||||
|
Write-Host " Deploy kit staged in: $OutDir"
|
||||||
|
Write-Host " Copy that folder to each render node, then follow README.md."
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Install the FlatRender Node Agent as a Windows service (native sc.exe — no NSSM).
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Registers flatrender-node-agent.exe as an auto-start service that survives reboots
|
||||||
|
and auto-restarts on crash. Configuration is read from `agent.env` placed next to
|
||||||
|
the exe (see agent.env.example), so no per-service environment plumbing is needed.
|
||||||
|
|
||||||
|
Run from an ELEVATED PowerShell prompt (Administrator).
|
||||||
|
|
||||||
|
.PARAMETER ExePath
|
||||||
|
Path to flatrender-node-agent.exe. Defaults to the exe beside this script.
|
||||||
|
|
||||||
|
.PARAMETER ServiceName
|
||||||
|
Windows service name. Default: FlatRenderNodeAgent.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\install-service.ps1
|
||||||
|
.\install-service.ps1 -ExePath C:\flatrender\flatrender-node-agent.exe
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[string]$ExePath = (Join-Path $PSScriptRoot "flatrender-node-agent.exe"),
|
||||||
|
[string]$ServiceName = "FlatRenderNodeAgent",
|
||||||
|
[string]$DisplayName = "FlatRender Node Agent"
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
# ── Elevation check ───────────────────────────────────────────────────────────
|
||||||
|
$principal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent())
|
||||||
|
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
||||||
|
Write-Error "This script must be run as Administrator. Right-click PowerShell → Run as administrator."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Validate exe + config ─────────────────────────────────────────────────────
|
||||||
|
if (-not (Test-Path $ExePath)) {
|
||||||
|
Write-Error "Executable not found: $ExePath`nBuild it first (see README) and copy it here."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
$ExePath = (Resolve-Path $ExePath).Path
|
||||||
|
$envFile = Join-Path (Split-Path $ExePath) "agent.env"
|
||||||
|
if (-not (Test-Path $envFile)) {
|
||||||
|
Write-Warning "No agent.env found next to the exe at: $envFile"
|
||||||
|
Write-Warning "Copy agent.env.example → agent.env and fill in NODE_ID / NODE_HMAC_SECRET before the service will work."
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Remove any existing instance ──────────────────────────────────────────────
|
||||||
|
$existing = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
|
||||||
|
if ($existing) {
|
||||||
|
Write-Host "Service '$ServiceName' already exists — stopping and removing it first..."
|
||||||
|
if ($existing.Status -ne 'Stopped') { Stop-Service -Name $ServiceName -Force -ErrorAction SilentlyContinue }
|
||||||
|
& sc.exe delete $ServiceName | Out-Null
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Create the service ────────────────────────────────────────────────────────
|
||||||
|
# binPath must quote the exe path (spaces). start=auto → launches at boot.
|
||||||
|
Write-Host "Creating service '$ServiceName'..."
|
||||||
|
& sc.exe create $ServiceName binPath= "`"$ExePath`"" start= auto DisplayName= "$DisplayName" | Out-Null
|
||||||
|
& sc.exe description $ServiceName "FlatRender render-node agent: claims and renders After Effects jobs." | Out-Null
|
||||||
|
|
||||||
|
# ── Crash recovery: restart after 5s, three times, reset window 1 day ─────────
|
||||||
|
& sc.exe failure $ServiceName reset= 86400 actions= restart/5000/restart/5000/restart/5000 | Out-Null
|
||||||
|
|
||||||
|
# ── Start it ──────────────────────────────────────────────────────────────────
|
||||||
|
Write-Host "Starting service..."
|
||||||
|
Start-Service -Name $ServiceName
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
$svc = Get-Service -Name $ServiceName
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "✓ Installed and $($svc.Status)." -ForegroundColor Green
|
||||||
|
Write-Host " Service : $ServiceName"
|
||||||
|
Write-Host " Exe : $ExePath"
|
||||||
|
Write-Host " Config : $envFile"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " Health : curl http://localhost:7777/health"
|
||||||
|
Write-Host " Logs : Get-WinEvent -ProviderName 'Service Control Manager' | Select-Object -First 5"
|
||||||
|
Write-Host " Stop : Stop-Service $ServiceName"
|
||||||
|
Write-Host " Remove : .\uninstall-service.ps1"
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Install WireGuard and bring up the FlatRender mesh tunnel as a persistent service.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
- Verifies WireGuard is installed (downloads the MSI if missing and -Download is set).
|
||||||
|
- Installs the given .conf as a permanent WireGuard tunnel service (survives reboot).
|
||||||
|
- The tunnel auto-connects on boot, BEFORE the node-agent service starts, so the
|
||||||
|
agent can always reach the gateway over 10.66.0.0/24.
|
||||||
|
|
||||||
|
Run ELEVATED (Administrator).
|
||||||
|
|
||||||
|
.PARAMETER ConfigPath
|
||||||
|
Path to the filled-in WireGuard config (from wireguard-node.conf.template).
|
||||||
|
Default: wg-flatrender.conf beside this script.
|
||||||
|
|
||||||
|
.PARAMETER Download
|
||||||
|
If set and WireGuard is not installed, download + silently install the MSI.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\setup-wireguard.ps1 -ConfigPath .\wg-flatrender.conf
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[string]$ConfigPath = (Join-Path $PSScriptRoot "wg-flatrender.conf"),
|
||||||
|
[switch]$Download
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$principal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent())
|
||||||
|
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
||||||
|
Write-Error "This script must be run as Administrator."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Ensure WireGuard is installed ─────────────────────────────────────────────
|
||||||
|
$wg = "C:\Program Files\WireGuard\wireguard.exe"
|
||||||
|
if (-not (Test-Path $wg)) {
|
||||||
|
if ($Download) {
|
||||||
|
Write-Host "WireGuard not found — downloading installer..."
|
||||||
|
$msi = Join-Path $env:TEMP "wireguard.msi"
|
||||||
|
Invoke-WebRequest -Uri "https://download.wireguard.com/windows-client/wireguard-installer.exe" -OutFile $msi
|
||||||
|
Write-Host "Installing WireGuard silently..."
|
||||||
|
Start-Process -FilePath $msi -ArgumentList "/S" -Wait
|
||||||
|
} else {
|
||||||
|
Write-Error "WireGuard is not installed. Install it from https://www.wireguard.com/install/ or re-run with -Download."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Validate config ───────────────────────────────────────────────────────────
|
||||||
|
if (-not (Test-Path $ConfigPath)) {
|
||||||
|
Write-Error "Config not found: $ConfigPath`nCopy wireguard-node.conf.template, fill the placeholders, save as wg-flatrender.conf."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
$ConfigPath = (Resolve-Path $ConfigPath).Path
|
||||||
|
if ((Get-Content $ConfigPath -Raw) -match '<[A-Z_]+>') {
|
||||||
|
Write-Error "Config still contains <PLACEHOLDERS>. Fill in all four values before installing."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$tunnelName = [System.IO.Path]::GetFileNameWithoutExtension($ConfigPath)
|
||||||
|
|
||||||
|
# ── Remove existing tunnel of the same name ───────────────────────────────────
|
||||||
|
$svcName = "WireGuardTunnel`$$tunnelName"
|
||||||
|
if (Get-Service -Name $svcName -ErrorAction SilentlyContinue) {
|
||||||
|
Write-Host "Removing existing tunnel '$tunnelName'..."
|
||||||
|
& $wg /uninstalltunnelservice $tunnelName | Out-Null
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Install the tunnel as a service ───────────────────────────────────────────
|
||||||
|
Write-Host "Installing WireGuard tunnel '$tunnelName' as a boot service..."
|
||||||
|
& $wg /installtunnelservice $ConfigPath
|
||||||
|
Start-Sleep -Seconds 3
|
||||||
|
|
||||||
|
$svc = Get-Service -Name $svcName -ErrorAction SilentlyContinue
|
||||||
|
if ($svc -and $svc.Status -eq 'Running') {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "✓ WireGuard tunnel '$tunnelName' is up." -ForegroundColor Green
|
||||||
|
Write-Host " Verify : & '$wg' show"
|
||||||
|
Write-Host " Ping CP: ping 10.66.0.1"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " Next : install the node agent service (install-service.ps1) and point"
|
||||||
|
Write-Host " ORCHESTRATOR_URL in agent.env at the control plane's mesh IP."
|
||||||
|
} else {
|
||||||
|
Write-Warning "Tunnel service did not reach Running state. Check: & '$wg' show"
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Stop and remove the FlatRender Node Agent Windows service.
|
||||||
|
.EXAMPLE
|
||||||
|
.\uninstall-service.ps1
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[string]$ServiceName = "FlatRenderNodeAgent"
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$principal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent())
|
||||||
|
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
||||||
|
Write-Error "This script must be run as Administrator."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
|
||||||
|
if (-not $svc) {
|
||||||
|
Write-Host "Service '$ServiceName' is not installed — nothing to do."
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($svc.Status -ne 'Stopped') {
|
||||||
|
Write-Host "Stopping '$ServiceName'..."
|
||||||
|
Stop-Service -Name $ServiceName -Force -ErrorAction SilentlyContinue
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
}
|
||||||
|
|
||||||
|
& sc.exe delete $ServiceName | Out-Null
|
||||||
|
Write-Host "✓ Service '$ServiceName' removed." -ForegroundColor Green
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# WireGuard tunnel for a FlatRender render node.
|
||||||
|
#
|
||||||
|
# The render node only ever dials OUT to the control plane — it never needs a
|
||||||
|
# public IP or any inbound firewall rule. All traffic to the gateway / MinIO
|
||||||
|
# rides this encrypted tunnel, so nodes can live behind NAT, on home ADSL, or
|
||||||
|
# in any datacenter.
|
||||||
|
#
|
||||||
|
# Fill in the four <PLACEHOLDERS> below, save as `wg-flatrender.conf`, then run
|
||||||
|
# setup-wireguard.ps1 (or import it in the WireGuard GUI).
|
||||||
|
|
||||||
|
[Interface]
|
||||||
|
# This node's private key (generate on the node: `wg genkey`).
|
||||||
|
PrivateKey = <NODE_PRIVATE_KEY>
|
||||||
|
# This node's address inside the mesh. Pick a unique 10.66.0.x per node.
|
||||||
|
Address = 10.66.0.<NODE_NUMBER>/32
|
||||||
|
# Optional: keep DNS on the LAN; the tunnel only carries mesh traffic (see AllowedIPs).
|
||||||
|
# DNS = 1.1.1.1
|
||||||
|
|
||||||
|
[Peer]
|
||||||
|
# Control plane (gateway + MinIO host) public key (from the server: `wg show`).
|
||||||
|
PublicKey = <SERVER_PUBLIC_KEY>
|
||||||
|
# Public endpoint of the control plane: <public-ip-or-host>:51820
|
||||||
|
Endpoint = <SERVER_PUBLIC_ENDPOINT>:51820
|
||||||
|
# Only route the mesh subnet through the tunnel — everything else uses the normal
|
||||||
|
# internet path. 10.66.0.0/24 = the FlatRender control + render mesh.
|
||||||
|
AllowedIPs = 10.66.0.0/24
|
||||||
|
# Hold the NAT mapping open so the orchestrator can reach the node's :7777 health
|
||||||
|
# port and so long-poll claims stay alive behind home routers / CGNAT.
|
||||||
|
PersistentKeepalive = 25
|
||||||
@@ -2,12 +2,58 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// LoadEnvFile reads a simple KEY=VALUE file and sets any variables that are not
|
||||||
|
// already present in the environment. This lets the Windows service (installed
|
||||||
|
// via sc.exe, which has no per-service env support) be configured by dropping an
|
||||||
|
// `agent.env` file next to the executable — no registry edits required.
|
||||||
|
//
|
||||||
|
// Lookup order: $AGENT_ENV_FILE, then `agent.env` beside the exe, then `./agent.env`.
|
||||||
|
// Lines starting with # and blank lines are ignored. Existing env vars win, so an
|
||||||
|
// operator can still override any single value at the process level.
|
||||||
|
func LoadEnvFile() {
|
||||||
|
candidates := []string{}
|
||||||
|
if p := os.Getenv("AGENT_ENV_FILE"); p != "" {
|
||||||
|
candidates = append(candidates, p)
|
||||||
|
}
|
||||||
|
if exe, err := os.Executable(); err == nil {
|
||||||
|
candidates = append(candidates, filepath.Join(filepath.Dir(exe), "agent.env"))
|
||||||
|
}
|
||||||
|
candidates = append(candidates, "agent.env")
|
||||||
|
|
||||||
|
for _, path := range candidates {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
scanner := bufio.NewScanner(f)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key, val, ok := strings.Cut(line, "=")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key = strings.TrimSpace(key)
|
||||||
|
val = strings.Trim(strings.TrimSpace(val), `"'`)
|
||||||
|
if _, exists := os.LookupEnv(key); !exists {
|
||||||
|
_ = os.Setenv(key, val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.Close()
|
||||||
|
return // first file found wins
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Config holds all runtime settings for the node agent.
|
// Config holds all runtime settings for the node agent.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
// NodeID is the UUID of this render node, registered in the orchestrator.
|
// NodeID is the UUID of this render node, registered in the orchestrator.
|
||||||
@@ -59,6 +105,8 @@ type Config struct {
|
|||||||
// Load reads configuration from environment variables, returning an error
|
// Load reads configuration from environment variables, returning an error
|
||||||
// if any required variable is missing.
|
// if any required variable is missing.
|
||||||
func Load() (*Config, error) {
|
func Load() (*Config, error) {
|
||||||
|
// Pull in agent.env (if present) before reading the environment.
|
||||||
|
LoadEnvFile()
|
||||||
c := &Config{
|
c := &Config{
|
||||||
NodeID: os.Getenv("NODE_ID"),
|
NodeID: os.Getenv("NODE_ID"),
|
||||||
OrchestratorURL: getEnv("ORCHESTRATOR_URL", "http://localhost:8088"),
|
OrchestratorURL: getEnv("ORCHESTRATOR_URL", "http://localhost:8088"),
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ func main() {
|
|||||||
{
|
{
|
||||||
renders.GET("", renderH.List)
|
renders.GET("", renderH.List)
|
||||||
renders.POST("", renderH.Create)
|
renders.POST("", renderH.Create)
|
||||||
|
renders.GET("/active", renderH.Active)
|
||||||
renders.GET("/:job_id", renderH.Get)
|
renders.GET("/:job_id", renderH.Get)
|
||||||
renders.POST("/:job_id/cancel", renderH.Cancel)
|
renders.POST("/:job_id/cancel", renderH.Cancel)
|
||||||
renders.POST("/:job_id/stop", admin, renderH.Stop)
|
renders.POST("/:job_id/stop", admin, renderH.Stop)
|
||||||
|
|||||||
@@ -371,6 +371,45 @@ func (s *Store) GetJobByID(ctx context.Context, id, userID uuid.UUID) (*models.R
|
|||||||
return jobs[0], nil
|
return jobs[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CountActiveJobs returns how many non-terminal render jobs the user currently has.
|
||||||
|
// Used to enforce the per-user concurrent-render ceiling.
|
||||||
|
func (s *Store) CountActiveJobs(ctx context.Context, userID uuid.UUID) (int, error) {
|
||||||
|
var n int
|
||||||
|
err := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT COUNT(*) FROM render.render_jobs
|
||||||
|
WHERE user_id = $1
|
||||||
|
AND step NOT IN ('Done'::render_step, 'Failed'::render_step, 'Cancelled'::render_step)`,
|
||||||
|
userID).Scan(&n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListActiveJobs returns the user's in-flight render jobs (most recent first),
|
||||||
|
// lightweight projection for the app-wide mini progress widget.
|
||||||
|
func (s *Store) ListActiveJobs(ctx context.Context, userID uuid.UUID) ([]*models.RenderJob, error) {
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT id, saved_project_id, name, step, render_progress, image_preview_b64, created_at
|
||||||
|
FROM render.render_jobs
|
||||||
|
WHERE user_id = $1
|
||||||
|
AND step NOT IN ('Done'::render_step, 'Failed'::render_step, 'Cancelled'::render_step)
|
||||||
|
ORDER BY created_at DESC`,
|
||||||
|
userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []*models.RenderJob
|
||||||
|
for rows.Next() {
|
||||||
|
j := &models.RenderJob{}
|
||||||
|
if err := rows.Scan(&j.ID, &j.SavedProjectID, &j.Name, &j.Step,
|
||||||
|
&j.RenderProgress, &j.ImagePreviewB64, &j.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, j)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) CreateJob(ctx context.Context, userID, tenantID uuid.UUID, req *models.RenderJobCreateRequest) (*models.RenderJob, error) {
|
func (s *Store) CreateJob(ctx context.Context, userID, tenantID uuid.UUID, req *models.RenderJobCreateRequest) (*models.RenderJob, error) {
|
||||||
priceType := "Free"
|
priceType := "Free"
|
||||||
if req.PriceType != nil {
|
if req.PriceType != nil {
|
||||||
|
|||||||
@@ -54,6 +54,39 @@ func (h *RenderHandler) List(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GET /v1/renders/active
|
||||||
|
// Lightweight list of the user's in-flight renders + their ceiling — powers the
|
||||||
|
// app-wide mini progress widget and the "can I start another render?" check.
|
||||||
|
func (h *RenderHandler) Active(c *gin.Context) {
|
||||||
|
userID := middleware.GetUserID(c)
|
||||||
|
jobs, err := h.store.ListActiveJobs(c.Request.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, models.APIError{Code: "internal_error", Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if jobs == nil {
|
||||||
|
jobs = []*models.RenderJob{}
|
||||||
|
}
|
||||||
|
out := make([]gin.H, 0, len(jobs))
|
||||||
|
for _, j := range jobs {
|
||||||
|
out = append(out, gin.H{
|
||||||
|
"id": j.ID,
|
||||||
|
"saved_project_id": j.SavedProjectID,
|
||||||
|
"name": j.Name,
|
||||||
|
"step": j.Step,
|
||||||
|
"render_progress": j.RenderProgress,
|
||||||
|
"preview_b64": j.ImagePreviewB64,
|
||||||
|
"created_at": j.CreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
maxRenders := middleware.GetMaxRenders(c)
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"active": out,
|
||||||
|
"max_renders": maxRenders,
|
||||||
|
"can_start_new": len(out) < maxRenders,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// POST /v1/renders
|
// POST /v1/renders
|
||||||
func (h *RenderHandler) Create(c *gin.Context) {
|
func (h *RenderHandler) Create(c *gin.Context) {
|
||||||
userID := middleware.GetUserID(c)
|
userID := middleware.GetUserID(c)
|
||||||
@@ -65,6 +98,20 @@ func (h *RenderHandler) Create(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Concurrent-render ceiling: a user may run only `max_renders` renders at once
|
||||||
|
// (default 1; raised by gamification level or an admin grant via the JWT claim).
|
||||||
|
maxRenders := middleware.GetMaxRenders(c)
|
||||||
|
active, err := h.store.CountActiveJobs(c.Request.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("count active jobs failed (allowing render): %v", err)
|
||||||
|
} else if active >= maxRenders {
|
||||||
|
c.JSON(http.StatusConflict, models.APIError{
|
||||||
|
Code: "active_render_limit",
|
||||||
|
Message: "شما یک رندر در حال انجام دارید. برای شروع رندر جدید صبر کنید تا رندر فعلی کامل شود.",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Daily render-limit: consume one render charge (0 max = unlimited).
|
// Daily render-limit: consume one render charge (0 max = unlimited).
|
||||||
allowed, err := h.identity.Consume(c.Request.Context(), userID)
|
allowed, err := h.identity.Consume(c.Request.Context(), userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package middleware
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/flatrender/render-svc/internal/models"
|
"github.com/flatrender/render-svc/internal/models"
|
||||||
@@ -12,10 +13,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
CtxUserID = "user_id"
|
CtxUserID = "user_id"
|
||||||
CtxTenantID = "tenant_id"
|
CtxTenantID = "tenant_id"
|
||||||
CtxIsAdmin = "is_admin"
|
CtxIsAdmin = "is_admin"
|
||||||
CtxRole = "role"
|
CtxRole = "role"
|
||||||
|
CtxMaxRenders = "max_renders"
|
||||||
)
|
)
|
||||||
|
|
||||||
func JWTAuth(secret string) gin.HandlerFunc {
|
func JWTAuth(secret string) gin.HandlerFunc {
|
||||||
@@ -54,10 +56,25 @@ func JWTAuth(secret string) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
role, _ := claims["role"].(string)
|
role, _ := claims["role"].(string)
|
||||||
|
|
||||||
|
// max_renders: concurrent-render ceiling. Identity emits it as a string;
|
||||||
|
// also accept a JSON number. Default 1 when absent/unparseable.
|
||||||
|
maxRenders := 1
|
||||||
|
switch v := claims["max_renders"].(type) {
|
||||||
|
case string:
|
||||||
|
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||||
|
maxRenders = n
|
||||||
|
}
|
||||||
|
case float64:
|
||||||
|
if v >= 1 {
|
||||||
|
maxRenders = int(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
c.Set(CtxUserID, userID)
|
c.Set(CtxUserID, userID)
|
||||||
c.Set(CtxTenantID, tenantID)
|
c.Set(CtxTenantID, tenantID)
|
||||||
c.Set(CtxIsAdmin, isAdmin)
|
c.Set(CtxIsAdmin, isAdmin)
|
||||||
c.Set(CtxRole, role)
|
c.Set(CtxRole, role)
|
||||||
|
c.Set(CtxMaxRenders, maxRenders)
|
||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -111,3 +128,16 @@ func GetTenantID(c *gin.Context) uuid.UUID {
|
|||||||
id, _ := v.(uuid.UUID)
|
id, _ := v.(uuid.UUID)
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetMaxRenders returns the user's concurrent-render ceiling (default 1).
|
||||||
|
func GetMaxRenders(c *gin.Context) int {
|
||||||
|
v, ok := c.Get(CtxMaxRenders)
|
||||||
|
if !ok {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
n, _ := v.(int)
|
||||||
|
if n < 1 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { NextIntlClientProvider } from "next-intl";
|
|||||||
|
|
||||||
import { DirectionProvider } from "@/components/layout/DirectionProvider";
|
import { DirectionProvider } from "@/components/layout/DirectionProvider";
|
||||||
import { SiteChrome } from "@/components/layout/SiteChrome";
|
import { SiteChrome } from "@/components/layout/SiteChrome";
|
||||||
|
import { GlobalRenderProgress } from "@/components/render/GlobalRenderProgress";
|
||||||
import { getNavUser } from "@/lib/auth/session";
|
import { getNavUser } from "@/lib/auth/session";
|
||||||
import { routing } from "@/i18n/routing";
|
import { routing } from "@/i18n/routing";
|
||||||
import type { Locale } from "@/i18n/routing";
|
import type { Locale } from "@/i18n/routing";
|
||||||
@@ -115,6 +116,7 @@ export default async function LocaleLayout({
|
|||||||
<NextIntlClientProvider messages={messages} locale={locale}>
|
<NextIntlClientProvider messages={messages} locale={locale}>
|
||||||
<DirectionProvider dir={isRtl ? "rtl" : "ltr"}>
|
<DirectionProvider dir={isRtl ? "rtl" : "ltr"}>
|
||||||
<SiteChrome user={navUser}>{children}</SiteChrome>
|
<SiteChrome user={navUser}>{children}</SiteChrome>
|
||||||
|
<GlobalRenderProgress authed={!!navUser} />
|
||||||
</DirectionProvider>
|
</DirectionProvider>
|
||||||
</NextIntlClientProvider>
|
</NextIntlClientProvider>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -0,0 +1,341 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
CheckCircle2,
|
||||||
|
Download,
|
||||||
|
Link2,
|
||||||
|
Loader2,
|
||||||
|
RefreshCw,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { apiFetch } from "@/lib/api/fetch";
|
||||||
|
import { RENDER_EXPORT_PRESETS, type RenderExportPreset } from "@/lib/render-presets";
|
||||||
|
import type { RenderSettings } from "@/lib/render-schemas";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type Phase = "config" | "submitting" | "polling" | "completed" | "failed";
|
||||||
|
|
||||||
|
interface StatusResponse {
|
||||||
|
status: string;
|
||||||
|
progress: number;
|
||||||
|
outputUrl: string | null;
|
||||||
|
progressMessage?: string | null;
|
||||||
|
errorMessage?: string | null;
|
||||||
|
previewB64?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActiveRender {
|
||||||
|
id: string;
|
||||||
|
saved_project_id: string;
|
||||||
|
step: string;
|
||||||
|
render_progress: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RESOLUTIONS: RenderSettings["resolution"][] = ["720p", "1080p", "4K"];
|
||||||
|
const FPS_OPTIONS: RenderSettings["fps"][] = [24, 30, 60];
|
||||||
|
|
||||||
|
export default function RenderPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useParams<{ projectId: string }>();
|
||||||
|
const search = useSearchParams();
|
||||||
|
const projectId = params.projectId;
|
||||||
|
const presetKey = search.get("preset") as RenderExportPreset | null;
|
||||||
|
|
||||||
|
const [resolution, setResolution] = useState<RenderSettings["resolution"]>("1080p");
|
||||||
|
const [fps, setFps] = useState<RenderSettings["fps"]>(30);
|
||||||
|
const [phase, setPhase] = useState<Phase>("config");
|
||||||
|
const [jobId, setJobId] = useState<string | null>(null);
|
||||||
|
const [progress, setProgress] = useState(0);
|
||||||
|
const [progressMessage, setProgressMessage] = useState("");
|
||||||
|
const [previewB64, setPreviewB64] = useState<string | null>(null);
|
||||||
|
const [outputUrl, setOutputUrl] = useState<string | null>(null);
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
// An active render that belongs to a DIFFERENT project (blocks starting a new one).
|
||||||
|
const [blockingJobId, setBlockingJobId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Apply preset from the query (?preset=full)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!presetKey || !RENDER_EXPORT_PRESETS[presetKey]) return;
|
||||||
|
const cfg = RENDER_EXPORT_PRESETS[presetKey];
|
||||||
|
setResolution(cfg.settings.resolution);
|
||||||
|
setFps(cfg.settings.fps);
|
||||||
|
}, [presetKey]);
|
||||||
|
|
||||||
|
// On mount: resume this project's render if one is in flight, or flag a render
|
||||||
|
// running on another project so we can block starting a new one.
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await apiFetch("/api/render/active");
|
||||||
|
const data = (await res.json()) as { active?: ActiveRender[] };
|
||||||
|
if (cancelled) return;
|
||||||
|
const mine = data.active?.find((a) => a.saved_project_id === projectId);
|
||||||
|
if (mine) {
|
||||||
|
setJobId(mine.id);
|
||||||
|
setPhase("polling");
|
||||||
|
setProgress(mine.render_progress ?? 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const other = data.active?.[0];
|
||||||
|
if (other) setBlockingJobId(other.id);
|
||||||
|
} catch {
|
||||||
|
/* ignore — config view will show */
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
// Poll status while rendering.
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase !== "polling" || !jobId) return;
|
||||||
|
|
||||||
|
const poll = async () => {
|
||||||
|
try {
|
||||||
|
const res = await apiFetch(`/api/render/${jobId}/status`);
|
||||||
|
const data = (await res.json()) as StatusResponse;
|
||||||
|
if (!res.ok) {
|
||||||
|
setPhase("failed");
|
||||||
|
setErrorMessage("Could not fetch render status.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setProgress(data.progress ?? 0);
|
||||||
|
setProgressMessage(data.progressMessage ?? `Rendering… ${data.progress}%`);
|
||||||
|
if (data.previewB64) setPreviewB64(data.previewB64);
|
||||||
|
|
||||||
|
if (data.status === "completed" && data.outputUrl) {
|
||||||
|
setOutputUrl(data.outputUrl);
|
||||||
|
setProgress(100);
|
||||||
|
setPhase("completed");
|
||||||
|
} else if (data.status === "failed") {
|
||||||
|
setPhase("failed");
|
||||||
|
setErrorMessage(data.errorMessage ?? "Render failed.");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setPhase("failed");
|
||||||
|
setErrorMessage("Network error while polling status.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
poll();
|
||||||
|
const id = window.setInterval(poll, 2500);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [phase, jobId]);
|
||||||
|
|
||||||
|
const startRender = useCallback(async () => {
|
||||||
|
setPhase("submitting");
|
||||||
|
setErrorMessage(null);
|
||||||
|
try {
|
||||||
|
const res = await apiFetch("/api/render", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
projectId,
|
||||||
|
settings: { resolution, format: "mp4" as const, fps },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = (await res.json()) as { jobId?: string; error?: string; code?: string };
|
||||||
|
if (res.status === 409 || data.code === "active_render_limit") {
|
||||||
|
setPhase("config");
|
||||||
|
setErrorMessage(
|
||||||
|
data.error ?? "You already have an active render. Wait for it to finish."
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!res.ok || !data.jobId) {
|
||||||
|
setPhase("failed");
|
||||||
|
setErrorMessage(data.error ?? "Failed to start render.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setJobId(data.jobId);
|
||||||
|
setProgress(0);
|
||||||
|
setProgressMessage("Queued for rendering…");
|
||||||
|
setPhase("polling");
|
||||||
|
} catch {
|
||||||
|
setPhase("failed");
|
||||||
|
setErrorMessage("Could not reach the render service.");
|
||||||
|
}
|
||||||
|
}, [projectId, resolution, fps]);
|
||||||
|
|
||||||
|
const backToStudio = `/studio/video/${projectId}`;
|
||||||
|
const isBusy = phase === "submitting" || phase === "polling";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col bg-[#070811] text-gray-100">
|
||||||
|
{/* Top bar */}
|
||||||
|
<header className="flex h-14 shrink-0 items-center justify-between border-b border-[#1a1d2e] px-4">
|
||||||
|
<Link
|
||||||
|
href={backToStudio}
|
||||||
|
className="flex items-center gap-2 text-sm text-gray-400 hover:text-white"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
بازگشت به استودیو
|
||||||
|
</Link>
|
||||||
|
<span className="text-sm font-medium text-gray-300">خروجی رندر</span>
|
||||||
|
<span className="w-28" />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="mx-auto flex w-full max-w-5xl flex-1 flex-col items-center justify-center gap-6 p-6">
|
||||||
|
{/* Preview / hero */}
|
||||||
|
<div className="relative aspect-video w-full max-w-3xl overflow-hidden rounded-2xl border border-[#1a1d2e] bg-[#0c0e1a]">
|
||||||
|
{previewB64 ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={`data:image/png;base64,${previewB64}`}
|
||||||
|
alt="Render preview"
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
|
{isBusy ? (
|
||||||
|
<Loader2 className="h-10 w-10 animate-spin text-primary-500/40" />
|
||||||
|
) : phase === "completed" ? (
|
||||||
|
<CheckCircle2 className="h-12 w-12 text-emerald-400" />
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-gray-600">پیشنمایش رندر اینجا نمایش داده میشود</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isBusy && (
|
||||||
|
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent p-4">
|
||||||
|
<div className="mb-1.5 flex justify-between text-xs text-gray-300">
|
||||||
|
<span>{progressMessage || "در حال رندر…"}</span>
|
||||||
|
<span>{progress}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 overflow-hidden rounded-full bg-white/10">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-primary-500 transition-all duration-500"
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* State-specific panel */}
|
||||||
|
{phase === "completed" && outputUrl ? (
|
||||||
|
<div className="w-full max-w-md space-y-3 text-center">
|
||||||
|
<p className="text-lg font-semibold text-emerald-400">ویدیوی شما آماده است!</p>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<a
|
||||||
|
href={outputUrl}
|
||||||
|
download
|
||||||
|
className="inline-flex items-center justify-center gap-2 rounded-lg bg-primary-600 px-4 py-3 text-sm font-medium text-white hover:bg-primary-700"
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
دانلود MP4
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={outputUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center justify-center gap-2 rounded-lg border border-[#2a2d3e] px-4 py-2.5 text-sm text-gray-200 hover:bg-[#161a2b]"
|
||||||
|
>
|
||||||
|
<Link2 className="h-4 w-4" />
|
||||||
|
لینک اشتراکگذاری
|
||||||
|
</a>
|
||||||
|
<Link
|
||||||
|
href={backToStudio}
|
||||||
|
className="mt-1 text-xs text-gray-500 hover:text-gray-300"
|
||||||
|
>
|
||||||
|
بازگشت به استودیو
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : phase === "failed" ? (
|
||||||
|
<div className="w-full max-w-md space-y-3 text-center">
|
||||||
|
<p className="rounded-lg border border-red-900/50 bg-red-950/40 px-4 py-3 text-sm text-red-300">
|
||||||
|
{errorMessage ?? "خطایی رخ داد."}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={startRender}
|
||||||
|
className="inline-flex w-full items-center justify-center gap-2 rounded-lg bg-primary-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-primary-700"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
تلاش دوباره
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : isBusy ? (
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
میتوانید این صفحه را ببندید؛ رندر در پسزمینه ادامه مییابد و از هر صفحهای قابل پیگیری است.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
// Config
|
||||||
|
<div className="w-full max-w-md space-y-5">
|
||||||
|
{errorMessage && (
|
||||||
|
<p className="rounded-lg border border-amber-900/50 bg-amber-950/40 px-3 py-2 text-sm text-amber-300">
|
||||||
|
{errorMessage}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{blockingJobId && (
|
||||||
|
<div className="rounded-lg border border-amber-900/50 bg-amber-950/30 px-3 py-2.5 text-sm text-amber-200">
|
||||||
|
شما یک رندر فعال دارید.{" "}
|
||||||
|
<button
|
||||||
|
className="underline hover:text-white"
|
||||||
|
onClick={() => router.push(`/studio/render/${projectId}`)}
|
||||||
|
>
|
||||||
|
ابتدا آن را کامل کنید.
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-xs font-medium text-gray-400">کیفیت</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{RESOLUTIONS.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setResolution(item)}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 rounded-lg border py-2.5 text-sm font-medium",
|
||||||
|
resolution === item
|
||||||
|
? "border-primary-500 bg-primary-600/20 text-white"
|
||||||
|
: "border-[#2a2d3e] text-[#8b91a7] hover:border-[#3d4260]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-xs font-medium text-gray-400">نرخ فریم</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{FPS_OPTIONS.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFps(item)}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 rounded-lg border py-2.5 text-sm font-medium",
|
||||||
|
fps === item
|
||||||
|
? "border-primary-500 bg-primary-600/20 text-white"
|
||||||
|
: "border-[#2a2d3e] text-[#8b91a7] hover:border-[#3d4260]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item} fps
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={startRender}
|
||||||
|
disabled={!!blockingJobId}
|
||||||
|
className="w-full rounded-lg bg-primary-600 px-4 py-3 text-sm font-semibold text-white hover:bg-primary-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
شروع رندر
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -29,6 +29,10 @@ export async function POST(req: NextRequest) {
|
|||||||
if (!(file instanceof File)) {
|
if (!(file instanceof File)) {
|
||||||
return NextResponse.json({ error: "No file provided" }, { status: 400 });
|
return NextResponse.json({ error: "No file provided" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
// Optional: drop the upload into a specific media-library folder.
|
||||||
|
const folderId = form?.get("folder_id");
|
||||||
|
const targetFolderId =
|
||||||
|
typeof folderId === "string" && folderId ? folderId : undefined;
|
||||||
|
|
||||||
const auth = { Authorization: `Bearer ${token}` };
|
const auth = { Authorization: `Bearer ${token}` };
|
||||||
|
|
||||||
@@ -41,6 +45,7 @@ export async function POST(req: NextRequest) {
|
|||||||
filename: file.name,
|
filename: file.name,
|
||||||
mime_type: file.type || "application/octet-stream",
|
mime_type: file.type || "application/octet-stream",
|
||||||
size_bytes: file.size,
|
size_bytes: file.size,
|
||||||
|
target_folder_id: targetFolderId,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const presign = await presignRes.json().catch(() => null);
|
const presign = await presignRes.json().catch(() => null);
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { gatewayUrl } from "@/lib/api/gateway";
|
||||||
|
import { getAccessToken } from "@/lib/auth/session";
|
||||||
|
import { MINIO_PUBLIC_URL } from "@/lib/files";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
// Generous cap for editor/trimmer output (trimmed clips, high-res exports).
|
||||||
|
const MAX_BYTES = 200 * 1024 * 1024; // 200 MB
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic user-scoped upload: Browser → Next → MinIO (presign → PUT → confirm).
|
||||||
|
*
|
||||||
|
* Unlike /api/profile/upload (avatar-only, persists to Identity), this just stores
|
||||||
|
* the file in the user's `user-uploads` bucket and returns the public URL. Used by
|
||||||
|
* the Image Editor "Save to cloud" and the Video Trimmer "Save to cloud" actions so
|
||||||
|
* a user's work lands in their account instead of only a local download.
|
||||||
|
*/
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const token = await getAccessToken();
|
||||||
|
if (!token) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const form = await req.formData().catch(() => null);
|
||||||
|
const file = form?.get("file");
|
||||||
|
if (!(file instanceof File)) {
|
||||||
|
return NextResponse.json({ error: "No file provided" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (file.size > MAX_BYTES) {
|
||||||
|
return NextResponse.json({ error: "File too large (max 200MB)" }, { status: 413 });
|
||||||
|
}
|
||||||
|
const mime = file.type || "application/octet-stream";
|
||||||
|
if (!mime.startsWith("image/") && !mime.startsWith("video/")) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Only image or video files are allowed" },
|
||||||
|
{ status: 415 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const auth = { Authorization: `Bearer ${token}` };
|
||||||
|
|
||||||
|
// 1. presigned PUT URL
|
||||||
|
const presignRes = await fetch(gatewayUrl("/v1/files/presigned-upload"), {
|
||||||
|
method: "POST",
|
||||||
|
cache: "no-store",
|
||||||
|
headers: { ...auth, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
filename: file.name,
|
||||||
|
mime_type: mime,
|
||||||
|
size_bytes: file.size,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const presign = await presignRes.json().catch(() => null);
|
||||||
|
if (!presignRes.ok || !presign?.upload_url || !presign?.file_id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: presign?.error?.message ?? "Could not start upload" },
|
||||||
|
{ status: presignRes.status || 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. PUT the bytes to MinIO (server-side; reaches minio:9000)
|
||||||
|
const put = await fetch(presign.upload_url, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": mime },
|
||||||
|
body: Buffer.from(await file.arrayBuffer()),
|
||||||
|
});
|
||||||
|
if (!put.ok) {
|
||||||
|
return NextResponse.json({ error: "Upload to storage failed" }, { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. confirm
|
||||||
|
await fetch(gatewayUrl(`/v1/files/${presign.file_id}/confirm`), {
|
||||||
|
method: "POST",
|
||||||
|
cache: "no-store",
|
||||||
|
headers: auth,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. resolve the public URL
|
||||||
|
const detailRes = await fetch(gatewayUrl(`/v1/files/${presign.file_id}`), {
|
||||||
|
cache: "no-store",
|
||||||
|
headers: auth,
|
||||||
|
});
|
||||||
|
const detail = await detailRes.json().catch(() => null);
|
||||||
|
const bucket = detail?.minio_bucket ?? "user-uploads";
|
||||||
|
const key = detail?.minio_key;
|
||||||
|
const url = key ? `${MINIO_PUBLIC_URL}/${bucket}/${key}` : null;
|
||||||
|
|
||||||
|
return NextResponse.json({ id: presign.file_id, name: file.name, mime_type: mime, url });
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { gatewayUrl } from "@/lib/api/gateway";
|
||||||
|
import { getAccessToken } from "@/lib/auth/session";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export interface ActiveRender {
|
||||||
|
id: string;
|
||||||
|
saved_project_id: string;
|
||||||
|
name: string | null;
|
||||||
|
step: string;
|
||||||
|
render_progress: number;
|
||||||
|
preview_b64: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActiveRendersResponse {
|
||||||
|
active: ActiveRender[];
|
||||||
|
max_renders: number;
|
||||||
|
can_start_new: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user's in-flight renders + their concurrent-render ceiling. Powers the
|
||||||
|
* app-wide mini progress widget and the "can I start another render?" gate.
|
||||||
|
* Returns an empty set (not 401) when signed out, so the widget can mount globally.
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
const token = await getAccessToken();
|
||||||
|
const empty: ActiveRendersResponse = { active: [], max_renders: 1, can_start_new: true };
|
||||||
|
if (!token) return NextResponse.json(empty);
|
||||||
|
|
||||||
|
const res = await fetch(gatewayUrl("/v1/renders/active"), {
|
||||||
|
cache: "no-store",
|
||||||
|
headers: { Accept: "application/json", Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) return NextResponse.json(empty);
|
||||||
|
|
||||||
|
const data = (await res.json().catch(() => null)) as ActiveRendersResponse | null;
|
||||||
|
return NextResponse.json(data ?? empty);
|
||||||
|
}
|
||||||
@@ -2,13 +2,29 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import { FILE_TYPE_TABS, fetchFiles, fileUrl, humanSize, isImage, isVideo, type FileItem } from "@/lib/admin-files";
|
import {
|
||||||
|
FILE_TYPE_TABS,
|
||||||
|
fetchFiles,
|
||||||
|
fetchFolders,
|
||||||
|
createFolder,
|
||||||
|
deleteFolder,
|
||||||
|
fileUrl,
|
||||||
|
humanSize,
|
||||||
|
isImage,
|
||||||
|
isVideo,
|
||||||
|
type FileItem,
|
||||||
|
type FolderItem,
|
||||||
|
} from "@/lib/admin-files";
|
||||||
|
|
||||||
const card = "rounded-xl border border-[#1e2235] bg-[#0f1120]";
|
const card = "rounded-xl border border-[#1e2235] bg-[#0f1120]";
|
||||||
const btn = "rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-500 disabled:opacity-50";
|
const btn = "rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-500 disabled:opacity-50";
|
||||||
|
|
||||||
|
interface Crumb { id: string | null; name: string }
|
||||||
|
|
||||||
export function FileManager() {
|
export function FileManager() {
|
||||||
const [files, setFiles] = useState<FileItem[]>([]);
|
const [files, setFiles] = useState<FileItem[]>([]);
|
||||||
|
const [folders, setFolders] = useState<FolderItem[]>([]);
|
||||||
|
const [crumbs, setCrumbs] = useState<Crumb[]>([{ id: null, name: "کتابخانه" }]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -18,6 +34,8 @@ export function FileManager() {
|
|||||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const folderId = crumbs[crumbs.length - 1].id;
|
||||||
|
|
||||||
const toggleSel = (id: string) =>
|
const toggleSel = (id: string) =>
|
||||||
setSelected((s) => {
|
setSelected((s) => {
|
||||||
const n = new Set(s);
|
const n = new Set(s);
|
||||||
@@ -29,13 +47,18 @@ export function FileManager() {
|
|||||||
const reload = useCallback(async () => {
|
const reload = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
setFiles(await fetchFiles(search, type));
|
const [fl, fd] = await Promise.all([
|
||||||
|
fetchFiles(search, type, folderId),
|
||||||
|
fetchFolders(folderId),
|
||||||
|
]);
|
||||||
|
setFiles(fl);
|
||||||
|
setFolders(fd);
|
||||||
} catch {
|
} catch {
|
||||||
setError("بارگذاری فایلها ناموفق بود");
|
setError("بارگذاری فایلها ناموفق بود");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [search, type]);
|
}, [search, type, folderId]);
|
||||||
|
|
||||||
useEffect(() => { reload(); }, [reload]);
|
useEffect(() => { reload(); }, [reload]);
|
||||||
|
|
||||||
@@ -45,6 +68,7 @@ export function FileManager() {
|
|||||||
for (const file of Array.from(list)) {
|
for (const file of Array.from(list)) {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append("file", file);
|
fd.append("file", file);
|
||||||
|
if (folderId) fd.append("folder_id", folderId);
|
||||||
const res = await fetch("/api/admin/files/upload", { method: "POST", body: fd });
|
const res = await fetch("/api/admin/files/upload", { method: "POST", body: fd });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const d = await res.json().catch(() => null);
|
const d = await res.json().catch(() => null);
|
||||||
@@ -56,6 +80,24 @@ export function FileManager() {
|
|||||||
reload();
|
reload();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const enterFolder = (f: FolderItem) =>
|
||||||
|
setCrumbs((c) => [...c, { id: f.id, name: f.name }]);
|
||||||
|
|
||||||
|
const goToCrumb = (idx: number) => setCrumbs((c) => c.slice(0, idx + 1));
|
||||||
|
|
||||||
|
const newFolder = async () => {
|
||||||
|
const name = prompt("نام پوشهٔ جدید؟")?.trim();
|
||||||
|
if (!name) return;
|
||||||
|
if (await createFolder(name, folderId)) reload();
|
||||||
|
else setError("ساخت پوشه ناموفق بود");
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFolder = async (f: FolderItem) => {
|
||||||
|
if (!confirm(`پوشهٔ «${f.name}» حذف شود؟ (فایلهای داخل آن حذف نمیشوند)`)) return;
|
||||||
|
if (await deleteFolder(f.id)) reload();
|
||||||
|
else setError("حذف پوشه ناموفق بود");
|
||||||
|
};
|
||||||
|
|
||||||
const remove = async (f: FileItem) => {
|
const remove = async (f: FileItem) => {
|
||||||
if (!confirm(`«${f.name}» حذف شود؟`)) return;
|
if (!confirm(`«${f.name}» حذف شود؟`)) return;
|
||||||
const res = await fetch(`/api/admin/resource/files/${f.id}`, { method: "DELETE" });
|
const res = await fetch(`/api/admin/resource/files/${f.id}`, { method: "DELETE" });
|
||||||
@@ -83,12 +125,33 @@ export function FileManager() {
|
|||||||
<h1 className="text-xl font-semibold text-white">کتابخانه رسانه</h1>
|
<h1 className="text-xl font-semibold text-white">کتابخانه رسانه</h1>
|
||||||
<p className="mt-1 text-sm text-gray-400">آپلود و مدیریت تصاویر، ویدیوها و پروژههای افترافکت. نشانی هر فایل را میتوان در فرمها استفاده کرد.</p>
|
<p className="mt-1 text-sm text-gray-400">آپلود و مدیریت تصاویر، ویدیوها و پروژههای افترافکت. نشانی هر فایل را میتوان در فرمها استفاده کرد.</p>
|
||||||
</div>
|
</div>
|
||||||
<button className={btn} onClick={() => inputRef.current?.click()} disabled={uploading}>
|
<div className="flex gap-2">
|
||||||
{uploading ? "در حال آپلود…" : "+ آپلود فایل"}
|
<button className="rounded-lg border border-[#262b40] px-3 py-1.5 text-xs font-medium text-gray-200 hover:bg-[#161a2e]" onClick={newFolder}>
|
||||||
</button>
|
+ پوشهٔ جدید
|
||||||
|
</button>
|
||||||
|
<button className={btn} onClick={() => inputRef.current?.click()} disabled={uploading}>
|
||||||
|
{uploading ? "در حال آپلود…" : "+ آپلود فایل"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<input ref={inputRef} type="file" multiple className="hidden" accept="image/*,video/*,audio/*,.aep,.aepx,.zip" onChange={(e) => e.target.files && uploadFiles(e.target.files)} />
|
<input ref={inputRef} type="file" multiple className="hidden" accept="image/*,video/*,audio/*,.aep,.aepx,.zip" onChange={(e) => e.target.files && uploadFiles(e.target.files)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
<div className="flex flex-wrap items-center gap-1 text-xs text-gray-400">
|
||||||
|
{crumbs.map((c, i) => (
|
||||||
|
<span key={`${c.id ?? "root"}-${i}`} className="flex items-center gap-1">
|
||||||
|
{i > 0 && <span className="text-gray-600">/</span>}
|
||||||
|
<button
|
||||||
|
className={i === crumbs.length - 1 ? "text-gray-200" : "text-indigo-300 hover:underline"}
|
||||||
|
onClick={() => goToCrumb(i)}
|
||||||
|
disabled={i === crumbs.length - 1}
|
||||||
|
>
|
||||||
|
{c.name}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{FILE_TYPE_TABS.map((t) => (
|
{FILE_TYPE_TABS.map((t) => (
|
||||||
@@ -116,10 +179,38 @@ export function FileManager() {
|
|||||||
onDragOver={(e) => e.preventDefault()}
|
onDragOver={(e) => e.preventDefault()}
|
||||||
onDrop={(e) => { e.preventDefault(); if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files); }}
|
onDrop={(e) => { e.preventDefault(); if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files); }}
|
||||||
>
|
>
|
||||||
|
{/* Folders (hidden while searching, since search spans the whole library) */}
|
||||||
|
{!search && folders.length > 0 && (
|
||||||
|
<div className="mb-3 grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
|
||||||
|
{folders.map((fd) => (
|
||||||
|
<div
|
||||||
|
key={fd.id}
|
||||||
|
className="group relative flex cursor-pointer items-center gap-2 rounded-lg border border-[#262b40] bg-[#0c0e1a] p-3 hover:border-indigo-500"
|
||||||
|
onDoubleClick={() => enterFolder(fd)}
|
||||||
|
onClick={() => enterFolder(fd)}
|
||||||
|
>
|
||||||
|
<span className="text-lg" aria-hidden>📁</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-xs text-gray-200" title={fd.name}>{fd.name}</p>
|
||||||
|
<p className="text-[10px] text-gray-600">{(fd.file_count ?? 0).toLocaleString("fa-IR")} فایل</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="absolute end-1.5 top-1.5 rounded border border-red-500/30 px-1 py-0.5 text-[10px] text-red-300 opacity-0 hover:bg-red-500/10 group-hover:opacity-100"
|
||||||
|
onClick={(e) => { e.stopPropagation(); removeFolder(fd); }}
|
||||||
|
>
|
||||||
|
حذف
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="py-10 text-center text-sm text-gray-500">در حال بارگذاری…</p>
|
<p className="py-10 text-center text-sm text-gray-500">در حال بارگذاری…</p>
|
||||||
) : files.length === 0 ? (
|
) : files.length === 0 ? (
|
||||||
<p className="py-10 text-center text-sm text-gray-500">فایلی یافت نشد. فایل را اینجا بکشید و رها کنید یا روی آپلود بزنید.</p>
|
<p className="py-10 text-center text-sm text-gray-500">
|
||||||
|
{folders.length > 0 ? "این پوشه فایلی ندارد." : "فایلی یافت نشد. فایل را اینجا بکشید و رها کنید یا روی آپلود بزنید."}
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
|
||||||
{files.map((f) => {
|
{files.map((f) => {
|
||||||
|
|||||||
@@ -0,0 +1,707 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project-scoped editor for scenes, shared colors, and color presets.
|
||||||
|
* Opened from a template variant (project) row in TemplatesAdmin.
|
||||||
|
*
|
||||||
|
* All three resources live in content-svc and are keyed by `project_id`:
|
||||||
|
* GET/POST/PUT/DELETE /v1/scenes?project_id=
|
||||||
|
* GET/POST/PUT/DELETE /v1/shared-colors?project_id=
|
||||||
|
* GET/POST/PUT/DELETE /v1/color-presets?project_id=
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Scene {
|
||||||
|
id: string;
|
||||||
|
project_id: string;
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
scene_type: string;
|
||||||
|
default_duration_sec?: number | null;
|
||||||
|
sort: number;
|
||||||
|
is_active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SharedColor {
|
||||||
|
id: string;
|
||||||
|
project_id: string;
|
||||||
|
element_key: string;
|
||||||
|
title: string;
|
||||||
|
default_color: string;
|
||||||
|
sort: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PresetItem {
|
||||||
|
id?: string;
|
||||||
|
element_key: string;
|
||||||
|
value: string;
|
||||||
|
sort: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ColorPreset {
|
||||||
|
id: string;
|
||||||
|
project_id: string;
|
||||||
|
name?: string | null;
|
||||||
|
sort: number;
|
||||||
|
items: PresetItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type Tab = "scenes" | "colors" | "presets";
|
||||||
|
|
||||||
|
const inp =
|
||||||
|
"w-full rounded-lg border border-[#262b40] bg-[#0c0e1a] px-2.5 py-1.5 text-xs text-gray-200 placeholder:text-gray-600 focus:border-indigo-500 focus:outline-none";
|
||||||
|
const btn =
|
||||||
|
"rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-500 disabled:opacity-50";
|
||||||
|
const ghost =
|
||||||
|
"rounded-lg border border-[#262b40] px-2.5 py-1 text-xs text-gray-300 hover:bg-[#161a2b]";
|
||||||
|
const del =
|
||||||
|
"rounded-lg border border-red-500/30 px-2 py-1 text-xs text-red-300 hover:bg-red-500/10";
|
||||||
|
const lbl = "mb-0.5 block text-[10px] text-gray-500";
|
||||||
|
|
||||||
|
const SCENE_TYPES = ["Intro", "Main", "Outro", "Transition", "Logo", "Lower-Third"];
|
||||||
|
|
||||||
|
export function SceneColorEditor({
|
||||||
|
projectId,
|
||||||
|
projectName,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
projectId: string;
|
||||||
|
projectName: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [tab, setTab] = useState<Tab>("scenes");
|
||||||
|
const [scenes, setScenes] = useState<Scene[]>([]);
|
||||||
|
const [colors, setColors] = useState<SharedColor[]>([]);
|
||||||
|
const [presets, setPresets] = useState<ColorPreset[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const api = (resource: string, suffix = "") =>
|
||||||
|
`/api/admin/resource/${resource}${suffix}?project_id=${projectId}`;
|
||||||
|
|
||||||
|
const reload = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const [s, c, p] = await Promise.all([
|
||||||
|
fetch(api("scenes"), { cache: "no-store" }).then((r) => r.json()),
|
||||||
|
fetch(api("shared-colors"), { cache: "no-store" }).then((r) => r.json()),
|
||||||
|
fetch(api("color-presets"), { cache: "no-store" }).then((r) => r.json()),
|
||||||
|
]);
|
||||||
|
setScenes(Array.isArray(s) ? s : (s?.items ?? []));
|
||||||
|
setColors(Array.isArray(c) ? c : (c?.items ?? []));
|
||||||
|
setPresets(Array.isArray(p) ? p : (p?.items ?? []));
|
||||||
|
} catch {
|
||||||
|
setError("بارگذاری اطلاعات ناموفق بود");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reload();
|
||||||
|
}, [reload]);
|
||||||
|
|
||||||
|
// The proxy appends ?project_id= via api(); for writes the body also carries it.
|
||||||
|
const writeUrl = (resource: string, id?: string) =>
|
||||||
|
id ? api(resource, `/${id}`) : api(resource);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/70 p-4">
|
||||||
|
<div className="flex max-h-[88vh] w-full max-w-3xl flex-col overflow-hidden rounded-2xl border border-[#1e2235] bg-[#0a0c16]">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between border-b border-[#1e2235] px-5 py-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-gray-100">صحنهها و رنگها</h3>
|
||||||
|
<p className="text-[11px] text-gray-500">نسخه: {projectName}</p>
|
||||||
|
</div>
|
||||||
|
<button className={ghost} onClick={onClose}>
|
||||||
|
بستن
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-1 border-b border-[#1e2235] px-3 pt-2">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
["scenes", `صحنهها (${scenes.length})`],
|
||||||
|
["colors", `رنگهای مشترک (${colors.length})`],
|
||||||
|
["presets", `پالتها (${presets.length})`],
|
||||||
|
] as [Tab, string][]
|
||||||
|
).map(([key, label]) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onClick={() => setTab(key)}
|
||||||
|
className={
|
||||||
|
tab === key
|
||||||
|
? "rounded-t-lg border-b-2 border-indigo-500 px-3 py-2 text-xs font-medium text-white"
|
||||||
|
: "px-3 py-2 text-xs text-gray-500 hover:text-gray-300"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
|
{error && (
|
||||||
|
<p className="mb-3 rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-300">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{loading ? (
|
||||||
|
<p className="py-8 text-center text-xs text-gray-500">در حال بارگذاری…</p>
|
||||||
|
) : tab === "scenes" ? (
|
||||||
|
<ScenesTab
|
||||||
|
projectId={projectId}
|
||||||
|
scenes={scenes}
|
||||||
|
writeUrl={writeUrl}
|
||||||
|
onChange={reload}
|
||||||
|
setError={setError}
|
||||||
|
/>
|
||||||
|
) : tab === "colors" ? (
|
||||||
|
<ColorsTab
|
||||||
|
projectId={projectId}
|
||||||
|
colors={colors}
|
||||||
|
writeUrl={writeUrl}
|
||||||
|
onChange={reload}
|
||||||
|
setError={setError}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<PresetsTab
|
||||||
|
projectId={projectId}
|
||||||
|
presets={presets}
|
||||||
|
writeUrl={writeUrl}
|
||||||
|
onChange={reload}
|
||||||
|
setError={setError}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scenes ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ScenesTab({
|
||||||
|
projectId,
|
||||||
|
scenes,
|
||||||
|
writeUrl,
|
||||||
|
onChange,
|
||||||
|
setError,
|
||||||
|
}: {
|
||||||
|
projectId: string;
|
||||||
|
scenes: Scene[];
|
||||||
|
writeUrl: (r: string, id?: string) => string;
|
||||||
|
onChange: () => void;
|
||||||
|
setError: (s: string | null) => void;
|
||||||
|
}) {
|
||||||
|
const empty = { key: "", title: "", scene_type: "Main", default_duration_sec: 5, sort: 0, is_active: true };
|
||||||
|
const [draft, setDraft] = useState<typeof empty>({ ...empty });
|
||||||
|
const [editId, setEditId] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
const body = { project_id: projectId, ...draft };
|
||||||
|
const res = await fetch(writeUrl("scenes", editId ?? undefined), {
|
||||||
|
method: editId ? "PUT" : "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
setBusy(false);
|
||||||
|
if (res.ok) {
|
||||||
|
setDraft({ ...empty });
|
||||||
|
setEditId(null);
|
||||||
|
onChange();
|
||||||
|
} else {
|
||||||
|
setError("ذخیرهٔ صحنه ناموفق بود");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async (s: Scene) => {
|
||||||
|
if (!confirm(`صحنهٔ «${s.title}» حذف شود؟`)) return;
|
||||||
|
const res = await fetch(writeUrl("scenes", s.id), { method: "DELETE" });
|
||||||
|
if (res.ok) onChange();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{scenes.length === 0 && (
|
||||||
|
<p className="text-xs text-gray-600">هنوز صحنهای تعریف نشده است.</p>
|
||||||
|
)}
|
||||||
|
{scenes
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.sort - b.sort)
|
||||||
|
.map((s) => (
|
||||||
|
<div
|
||||||
|
key={s.id}
|
||||||
|
className="flex flex-wrap items-center gap-2 rounded-lg border border-[#1e2235] bg-[#0c0e1a] p-2"
|
||||||
|
>
|
||||||
|
<span className="rounded bg-indigo-500/15 px-1.5 py-0.5 text-[10px] text-indigo-300">
|
||||||
|
{s.scene_type}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 truncate text-xs text-gray-200">
|
||||||
|
{s.title}{" "}
|
||||||
|
<span className="text-gray-600" dir="ltr">
|
||||||
|
({s.key})
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-gray-500">{s.default_duration_sec ?? "—"}s</span>
|
||||||
|
<span className="text-[11px] text-gray-600">#{s.sort}</span>
|
||||||
|
{!s.is_active && (
|
||||||
|
<span className="rounded bg-gray-500/15 px-1.5 py-0.5 text-[10px] text-gray-400">
|
||||||
|
غیرفعال
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className={ghost}
|
||||||
|
onClick={() => {
|
||||||
|
setEditId(s.id);
|
||||||
|
setDraft({
|
||||||
|
key: s.key,
|
||||||
|
title: s.title,
|
||||||
|
scene_type: s.scene_type,
|
||||||
|
default_duration_sec: s.default_duration_sec ?? 5,
|
||||||
|
sort: s.sort,
|
||||||
|
is_active: s.is_active,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
ویرایش
|
||||||
|
</button>
|
||||||
|
<button className={del} onClick={() => remove(s)}>
|
||||||
|
حذف
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add / edit form */}
|
||||||
|
<div className="rounded-lg border border-dashed border-[#262b40] p-3">
|
||||||
|
<p className="mb-2 text-[11px] font-medium text-gray-400">
|
||||||
|
{editId ? "ویرایش صحنه" : "افزودن صحنه"}
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<label className={lbl}>کلید (یکتا)</label>
|
||||||
|
<input
|
||||||
|
className={inp}
|
||||||
|
dir="ltr"
|
||||||
|
value={draft.key}
|
||||||
|
onChange={(e) => setDraft({ ...draft, key: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={lbl}>عنوان</label>
|
||||||
|
<input
|
||||||
|
className={inp}
|
||||||
|
value={draft.title}
|
||||||
|
onChange={(e) => setDraft({ ...draft, title: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={lbl}>نوع</label>
|
||||||
|
<select
|
||||||
|
className={inp}
|
||||||
|
value={draft.scene_type}
|
||||||
|
onChange={(e) => setDraft({ ...draft, scene_type: e.target.value })}
|
||||||
|
>
|
||||||
|
{SCENE_TYPES.map((t) => (
|
||||||
|
<option key={t} value={t}>
|
||||||
|
{t}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={lbl}>مدت پیشفرض (ثانیه)</label>
|
||||||
|
<input
|
||||||
|
className={inp}
|
||||||
|
type="number"
|
||||||
|
step="0.5"
|
||||||
|
value={draft.default_duration_sec}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDraft({ ...draft, default_duration_sec: Number(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={lbl}>ترتیب</label>
|
||||||
|
<input
|
||||||
|
className={inp}
|
||||||
|
type="number"
|
||||||
|
value={draft.sort}
|
||||||
|
onChange={(e) => setDraft({ ...draft, sort: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end gap-1.5 pb-1">
|
||||||
|
<input
|
||||||
|
id="scene-active"
|
||||||
|
type="checkbox"
|
||||||
|
checked={draft.is_active}
|
||||||
|
onChange={(e) => setDraft({ ...draft, is_active: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<label htmlFor="scene-active" className="text-xs text-gray-400">
|
||||||
|
فعال
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex gap-2">
|
||||||
|
<button className={btn} onClick={submit} disabled={busy || !draft.key || !draft.title}>
|
||||||
|
{busy ? "…" : editId ? "ذخیرهٔ تغییرات" : "+ افزودن"}
|
||||||
|
</button>
|
||||||
|
{editId && (
|
||||||
|
<button
|
||||||
|
className={ghost}
|
||||||
|
onClick={() => {
|
||||||
|
setEditId(null);
|
||||||
|
setDraft({ ...empty });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
انصراف
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shared colors ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ColorsTab({
|
||||||
|
projectId,
|
||||||
|
colors,
|
||||||
|
writeUrl,
|
||||||
|
onChange,
|
||||||
|
setError,
|
||||||
|
}: {
|
||||||
|
projectId: string;
|
||||||
|
colors: SharedColor[];
|
||||||
|
writeUrl: (r: string, id?: string) => string;
|
||||||
|
onChange: () => void;
|
||||||
|
setError: (s: string | null) => void;
|
||||||
|
}) {
|
||||||
|
const empty = { element_key: "", title: "", default_color: "#4c6ef5", sort: 0 };
|
||||||
|
const [draft, setDraft] = useState<typeof empty>({ ...empty });
|
||||||
|
const [editId, setEditId] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
const body = { project_id: projectId, attr_value: draft.default_color, ...draft };
|
||||||
|
const res = await fetch(writeUrl("shared-colors", editId ?? undefined), {
|
||||||
|
method: editId ? "PUT" : "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
setBusy(false);
|
||||||
|
if (res.ok) {
|
||||||
|
setDraft({ ...empty });
|
||||||
|
setEditId(null);
|
||||||
|
onChange();
|
||||||
|
} else {
|
||||||
|
setError("ذخیرهٔ رنگ ناموفق بود");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async (c: SharedColor) => {
|
||||||
|
if (!confirm(`رنگ «${c.title}» حذف شود؟`)) return;
|
||||||
|
const res = await fetch(writeUrl("shared-colors", c.id), { method: "DELETE" });
|
||||||
|
if (res.ok) onChange();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{colors.length === 0 && (
|
||||||
|
<p className="text-xs text-gray-600">هنوز رنگ مشترکی تعریف نشده است.</p>
|
||||||
|
)}
|
||||||
|
{colors
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.sort - b.sort)
|
||||||
|
.map((c) => (
|
||||||
|
<div
|
||||||
|
key={c.id}
|
||||||
|
className="flex flex-wrap items-center gap-2 rounded-lg border border-[#1e2235] bg-[#0c0e1a] p-2"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="h-5 w-5 shrink-0 rounded border border-white/10"
|
||||||
|
style={{ background: c.default_color }}
|
||||||
|
/>
|
||||||
|
<span className="flex-1 truncate text-xs text-gray-200">
|
||||||
|
{c.title}{" "}
|
||||||
|
<span className="text-gray-600" dir="ltr">
|
||||||
|
({c.element_key})
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-gray-500" dir="ltr">
|
||||||
|
{c.default_color}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-gray-600">#{c.sort}</span>
|
||||||
|
<button
|
||||||
|
className={ghost}
|
||||||
|
onClick={() => {
|
||||||
|
setEditId(c.id);
|
||||||
|
setDraft({
|
||||||
|
element_key: c.element_key,
|
||||||
|
title: c.title,
|
||||||
|
default_color: c.default_color,
|
||||||
|
sort: c.sort,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
ویرایش
|
||||||
|
</button>
|
||||||
|
<button className={del} onClick={() => remove(c)}>
|
||||||
|
حذف
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-dashed border-[#262b40] p-3">
|
||||||
|
<p className="mb-2 text-[11px] font-medium text-gray-400">
|
||||||
|
{editId ? "ویرایش رنگ" : "افزودن رنگ مشترک"}
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||||
|
<div>
|
||||||
|
<label className={lbl}>کلید عنصر</label>
|
||||||
|
<input
|
||||||
|
className={inp}
|
||||||
|
dir="ltr"
|
||||||
|
value={draft.element_key}
|
||||||
|
onChange={(e) => setDraft({ ...draft, element_key: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={lbl}>عنوان</label>
|
||||||
|
<input
|
||||||
|
className={inp}
|
||||||
|
value={draft.title}
|
||||||
|
onChange={(e) => setDraft({ ...draft, title: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={lbl}>رنگ</label>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
className="h-8 w-9 shrink-0 rounded border border-[#262b40] bg-transparent"
|
||||||
|
value={draft.default_color}
|
||||||
|
onChange={(e) => setDraft({ ...draft, default_color: e.target.value })}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className={inp}
|
||||||
|
dir="ltr"
|
||||||
|
value={draft.default_color}
|
||||||
|
onChange={(e) => setDraft({ ...draft, default_color: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={lbl}>ترتیب</label>
|
||||||
|
<input
|
||||||
|
className={inp}
|
||||||
|
type="number"
|
||||||
|
value={draft.sort}
|
||||||
|
onChange={(e) => setDraft({ ...draft, sort: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex gap-2">
|
||||||
|
<button
|
||||||
|
className={btn}
|
||||||
|
onClick={submit}
|
||||||
|
disabled={busy || !draft.element_key || !draft.title}
|
||||||
|
>
|
||||||
|
{busy ? "…" : editId ? "ذخیرهٔ تغییرات" : "+ افزودن"}
|
||||||
|
</button>
|
||||||
|
{editId && (
|
||||||
|
<button
|
||||||
|
className={ghost}
|
||||||
|
onClick={() => {
|
||||||
|
setEditId(null);
|
||||||
|
setDraft({ ...empty });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
انصراف
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Color presets ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function PresetsTab({
|
||||||
|
projectId,
|
||||||
|
presets,
|
||||||
|
writeUrl,
|
||||||
|
onChange,
|
||||||
|
setError,
|
||||||
|
}: {
|
||||||
|
projectId: string;
|
||||||
|
presets: ColorPreset[];
|
||||||
|
writeUrl: (r: string, id?: string) => string;
|
||||||
|
onChange: () => void;
|
||||||
|
setError: (s: string | null) => void;
|
||||||
|
}) {
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [items, setItems] = useState<PresetItem[]>([{ element_key: "", value: "#ffffff", sort: 0 }]);
|
||||||
|
const [editId, setEditId] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setName("");
|
||||||
|
setItems([{ element_key: "", value: "#ffffff", sort: 0 }]);
|
||||||
|
setEditId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
const body = {
|
||||||
|
project_id: projectId,
|
||||||
|
name: name || null,
|
||||||
|
sort: 0,
|
||||||
|
items: items
|
||||||
|
.filter((i) => i.element_key)
|
||||||
|
.map((i, idx) => ({ element_key: i.element_key, value: i.value, sort: idx })),
|
||||||
|
};
|
||||||
|
const res = await fetch(writeUrl("color-presets", editId ?? undefined), {
|
||||||
|
method: editId ? "PUT" : "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
setBusy(false);
|
||||||
|
if (res.ok) {
|
||||||
|
reset();
|
||||||
|
onChange();
|
||||||
|
} else {
|
||||||
|
setError("ذخیرهٔ پالت ناموفق بود");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async (p: ColorPreset) => {
|
||||||
|
if (!confirm(`پالت حذف شود؟`)) return;
|
||||||
|
const res = await fetch(writeUrl("color-presets", p.id), { method: "DELETE" });
|
||||||
|
if (res.ok) onChange();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{presets.length === 0 && (
|
||||||
|
<p className="text-xs text-gray-600">هنوز پالتی تعریف نشده است.</p>
|
||||||
|
)}
|
||||||
|
{presets.map((p) => (
|
||||||
|
<div
|
||||||
|
key={p.id}
|
||||||
|
className="flex flex-wrap items-center gap-2 rounded-lg border border-[#1e2235] bg-[#0c0e1a] p-2"
|
||||||
|
>
|
||||||
|
<span className="flex-1 truncate text-xs text-gray-200">
|
||||||
|
{p.name || "بدون نام"}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{p.items?.slice(0, 8).map((it, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
title={`${it.element_key}: ${it.value}`}
|
||||||
|
className="h-4 w-4 rounded-sm border border-white/10"
|
||||||
|
style={{ background: it.value }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className={ghost}
|
||||||
|
onClick={() => {
|
||||||
|
setEditId(p.id);
|
||||||
|
setName(p.name ?? "");
|
||||||
|
setItems(
|
||||||
|
p.items?.length
|
||||||
|
? p.items.map((i) => ({ element_key: i.element_key, value: i.value, sort: i.sort }))
|
||||||
|
: [{ element_key: "", value: "#ffffff", sort: 0 }]
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
ویرایش
|
||||||
|
</button>
|
||||||
|
<button className={del} onClick={() => remove(p)}>
|
||||||
|
حذف
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-dashed border-[#262b40] p-3">
|
||||||
|
<p className="mb-2 text-[11px] font-medium text-gray-400">
|
||||||
|
{editId ? "ویرایش پالت" : "افزودن پالت رنگ"}
|
||||||
|
</p>
|
||||||
|
<div className="mb-2">
|
||||||
|
<label className={lbl}>نام پالت (اختیاری)</label>
|
||||||
|
<input className={inp} value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{items.map((it, idx) => (
|
||||||
|
<div key={idx} className="flex items-center gap-1.5">
|
||||||
|
<input
|
||||||
|
className={inp}
|
||||||
|
dir="ltr"
|
||||||
|
placeholder="element_key"
|
||||||
|
value={it.element_key}
|
||||||
|
onChange={(e) =>
|
||||||
|
setItems((arr) =>
|
||||||
|
arr.map((x, i) => (i === idx ? { ...x, element_key: e.target.value } : x))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
className="h-8 w-9 shrink-0 rounded border border-[#262b40] bg-transparent"
|
||||||
|
value={it.value}
|
||||||
|
onChange={(e) =>
|
||||||
|
setItems((arr) =>
|
||||||
|
arr.map((x, i) => (i === idx ? { ...x, value: e.target.value } : x))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className={del}
|
||||||
|
onClick={() => setItems((arr) => arr.filter((_, i) => i !== idx))}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className={`${ghost} mt-2`}
|
||||||
|
onClick={() => setItems((arr) => [...arr, { element_key: "", value: "#ffffff", sort: arr.length }])}
|
||||||
|
>
|
||||||
|
+ رنگ
|
||||||
|
</button>
|
||||||
|
<div className="mt-2 flex gap-2">
|
||||||
|
<button className={btn} onClick={submit} disabled={busy}>
|
||||||
|
{busy ? "…" : editId ? "ذخیرهٔ تغییرات" : "+ افزودن پالت"}
|
||||||
|
</button>
|
||||||
|
{editId && (
|
||||||
|
<button className={ghost} onClick={reset}>
|
||||||
|
انصراف
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useState } from "react";
|
|||||||
import { slugify } from "@/components/admin/AdminResource";
|
import { slugify } from "@/components/admin/AdminResource";
|
||||||
import { FileUploadField } from "@/components/admin/FileUploadField";
|
import { FileUploadField } from "@/components/admin/FileUploadField";
|
||||||
import { AdminThumb } from "@/components/admin/AdminThumb";
|
import { AdminThumb } from "@/components/admin/AdminThumb";
|
||||||
|
import { SceneColorEditor } from "@/components/admin/SceneColorEditor";
|
||||||
|
|
||||||
interface Container {
|
interface Container {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -74,6 +75,7 @@ export function TemplatesAdmin() {
|
|||||||
const emptyNewProj = { name: "", width: 1920, height: 1080, aspect: "16:9", resolution: "FullHD", duration: 15, fps: 30, mode: "FLEXIBLE" };
|
const emptyNewProj = { name: "", width: 1920, height: 1080, aspect: "16:9", resolution: "FullHD", duration: 15, fps: 30, mode: "FLEXIBLE" };
|
||||||
const [newProj, setNewProj] = useState({ ...emptyNewProj });
|
const [newProj, setNewProj] = useState({ ...emptyNewProj });
|
||||||
const [addingProj, setAddingProj] = useState(false);
|
const [addingProj, setAddingProj] = useState(false);
|
||||||
|
const [scEditor, setScEditor] = useState<{ id: string; name: string } | null>(null);
|
||||||
|
|
||||||
const api = (p: string) => `/api/admin/resource/${p}`;
|
const api = (p: string) => `/api/admin/resource/${p}`;
|
||||||
|
|
||||||
@@ -352,6 +354,11 @@ export function TemplatesAdmin() {
|
|||||||
<button type="button" className={ghost} onClick={() => saveComp(p)} disabled={savingProj === p.id}>ذخیره</button>
|
<button type="button" className={ghost} onClick={() => saveComp(p)} disabled={savingProj === p.id}>ذخیره</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-end">
|
||||||
|
<button type="button" className="rounded-lg border border-indigo-500/40 px-2.5 py-1 text-xs text-indigo-300 hover:bg-indigo-500/10" onClick={() => setScEditor({ id: p.id, name: p.name })}>
|
||||||
|
صحنهها و رنگها
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -388,6 +395,14 @@ export function TemplatesAdmin() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{scEditor && (
|
||||||
|
<SceneColorEditor
|
||||||
|
projectId={scEditor.id}
|
||||||
|
projectName={scEditor.name}
|
||||||
|
onClose={() => setScEditor(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export function UserActions({ row }: { row: Record<string, unknown>; reload?: ()
|
|||||||
const [pw, setPw] = useState("");
|
const [pw, setPw] = useState("");
|
||||||
const [seconds, setSeconds] = useState(""); const [renders, setRenders] = useState("");
|
const [seconds, setSeconds] = useState(""); const [renders, setRenders] = useState("");
|
||||||
const [planDays, setPlanDays] = useState("");
|
const [planDays, setPlanDays] = useState("");
|
||||||
|
const [slots, setSlots] = useState(String(row.parallel_rendering_ceiling ?? "1"));
|
||||||
const [tags, setTags] = useState(""); const [note, setNote] = useState(""); const [status, setStatus] = useState("new");
|
const [tags, setTags] = useState(""); const [note, setNote] = useState(""); const [status, setStatus] = useState("new");
|
||||||
// discount / affiliate
|
// discount / affiliate
|
||||||
const [dcCode, setDcCode] = useState(""); const [dcKind, setDcKind] = useState("Percentage");
|
const [dcCode, setDcCode] = useState(""); const [dcKind, setDcKind] = useState("Percentage");
|
||||||
@@ -114,6 +115,15 @@ export function UserActions({ row }: { row: Record<string, unknown>; reload?: ()
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className={`${card} p-3`}>
|
||||||
|
<label className={lbl}>تعداد رندر همزمان مجاز</label>
|
||||||
|
<p className="mb-1.5 text-[11px] text-gray-500">پیشفرض ۱. با افزایش این مقدار، کاربر میتواند چند رندر همزمان اجرا کند (پس از تازهسازی توکن اعمال میشود).</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input className={`${inp} max-w-[120px]`} type="number" min={1} max={50} value={slots} onChange={(e) => setSlots(e.target.value)} />
|
||||||
|
<button className={btn} disabled={busy || !slots} onClick={() => call(`users/${id}/render-slots`, { ceiling: Number(slots) || 1 }, "ظرفیت رندر همزمان بهروزرسانی شد ✓")}>اعمال</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className={`${card} flex items-center justify-between p-3`}>
|
<div className={`${card} flex items-center justify-between p-3`}>
|
||||||
<span className="text-sm text-gray-300">دسترسی مدیر (مدراتور)</span>
|
<span className="text-sm text-gray-300">دسترسی مدیر (مدراتور)</span>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
|||||||
@@ -302,11 +302,13 @@ export const plansConfig: ResourceConfig = {
|
|||||||
|
|
||||||
export const discountsConfig: ResourceConfig = {
|
export const discountsConfig: ResourceConfig = {
|
||||||
title: "تخفیفها",
|
title: "تخفیفها",
|
||||||
description: "کدهای تخفیف / کوپن. (کدها اینجا ساخته میشوند؛ هنوز API ویرایش/حذف وجود ندارد.)",
|
description: "کدهای تخفیف / کوپن. ساخت، ویرایش و حذف.",
|
||||||
basePath: "discounts",
|
basePath: "discounts",
|
||||||
listQuery: "pageSize=500&page_size=500",
|
listQuery: "pageSize=500&page_size=500",
|
||||||
listKey: "data",
|
listKey: "data",
|
||||||
canCreate: true,
|
canCreate: true,
|
||||||
|
canEdit: true,
|
||||||
|
canDelete: true,
|
||||||
columns: [
|
columns: [
|
||||||
{ key: "code", label: "کد" },
|
{ key: "code", label: "کد" },
|
||||||
{ key: "kind", label: "نوع" },
|
{ key: "kind", label: "نوع" },
|
||||||
@@ -332,5 +334,6 @@ export const discountsConfig: ResourceConfig = {
|
|||||||
{ key: "value", label: "مقدار", type: "number", required: true },
|
{ key: "value", label: "مقدار", type: "number", required: true },
|
||||||
{ key: "max_use_count", label: "حداکثر دفعات استفاده (خالی = نامحدود)", type: "number" },
|
{ key: "max_use_count", label: "حداکثر دفعات استفاده (خالی = نامحدود)", type: "number" },
|
||||||
{ key: "expires_at", label: "تاریخ انقضا (ISO، اختیاری)", placeholder: "2026-12-31T00:00:00Z" },
|
{ key: "expires_at", label: "تاریخ انقضا (ISO، اختیاری)", placeholder: "2026-12-31T00:00:00Z" },
|
||||||
|
{ key: "is_active", label: "فعال", type: "checkbox", defaultValue: true },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,13 +3,13 @@
|
|||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Download, FolderOpen, Sparkles } from "lucide-react";
|
import { CloudUpload, Download, FolderOpen, Sparkles } from "lucide-react";
|
||||||
|
|
||||||
import { ProjectSaveIndicator } from "@/components/studio/ProjectSaveIndicator";
|
import { ProjectSaveIndicator } from "@/components/studio/ProjectSaveIndicator";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Slider } from "@/components/ui/slider";
|
import { Slider } from "@/components/ui/slider";
|
||||||
import { toast } from "@/components/ui/use-toast";
|
import { toast } from "@/components/ui/use-toast";
|
||||||
import { downloadStageImage } from "@/lib/image-editor-export";
|
import { downloadStageImage, saveStageToCloud } from "@/lib/image-editor-export";
|
||||||
import { getImageEditorStage } from "@/lib/image-editor-stage-ref";
|
import { getImageEditorStage } from "@/lib/image-editor-stage-ref";
|
||||||
import type { ExportImageFormat } from "@/lib/image-editor-types";
|
import type { ExportImageFormat } from "@/lib/image-editor-types";
|
||||||
import type { ProjectSaveStatus } from "@/lib/project-save-status";
|
import type { ProjectSaveStatus } from "@/lib/project-save-status";
|
||||||
@@ -32,6 +32,7 @@ export function ImageEditorTopBar({
|
|||||||
const t = useTranslations("auto.componentsImageEditorImageEditorTopBar");
|
const t = useTranslations("auto.componentsImageEditorImageEditorTopBar");
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
const [exportOpen, setExportOpen] = useState(false);
|
const [exportOpen, setExportOpen] = useState(false);
|
||||||
|
const [savingCloud, setSavingCloud] = useState(false);
|
||||||
|
|
||||||
const loadBaseImage = useImageEditorStore((s) => s.loadBaseImage);
|
const loadBaseImage = useImageEditorStore((s) => s.loadBaseImage);
|
||||||
const exportFormat = useImageEditorStore((s) => s.exportFormat);
|
const exportFormat = useImageEditorStore((s) => s.exportFormat);
|
||||||
@@ -62,6 +63,24 @@ export function ImageEditorTopBar({
|
|||||||
setExportOpen(false);
|
setExportOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSaveToCloud = async () => {
|
||||||
|
const stage = getImageEditorStage();
|
||||||
|
if (!stage) {
|
||||||
|
toast({ title: t("canvasNotReady") });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSavingCloud(true);
|
||||||
|
try {
|
||||||
|
const url = await saveStageToCloud(stage, exportFormat, exportQuality);
|
||||||
|
toast({ title: url ? t("savedToCloud") : t("saveToCloudFailed") });
|
||||||
|
if (url) setExportOpen(false);
|
||||||
|
} catch {
|
||||||
|
toast({ title: t("saveToCloudFailed") });
|
||||||
|
} finally {
|
||||||
|
setSavingCloud(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="flex h-14 shrink-0 items-center justify-between gap-4 border-b border-gray-800 bg-gray-900 px-4">
|
<header className="flex h-14 shrink-0 items-center justify-between gap-4 border-b border-gray-800 bg-gray-900 px-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -154,8 +173,19 @@ export function ImageEditorTopBar({
|
|||||||
className="w-full bg-primary-600 hover:bg-primary-700"
|
className="w-full bg-primary-600 hover:bg-primary-700"
|
||||||
onClick={handleExport}
|
onClick={handleExport}
|
||||||
>
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
{t("download")}
|
{t("download")}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="mt-2 w-full border-gray-700 bg-gray-800 text-gray-200"
|
||||||
|
onClick={handleSaveToCloud}
|
||||||
|
disabled={savingCloud}
|
||||||
|
>
|
||||||
|
<CloudUpload className="h-4 w-4" />
|
||||||
|
{savingCloud ? t("savingToCloud") : t("saveToCloud")}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
|
import { Film, Loader2, X } from "lucide-react";
|
||||||
|
|
||||||
|
interface ActiveRender {
|
||||||
|
id: string;
|
||||||
|
saved_project_id: string;
|
||||||
|
name: string | null;
|
||||||
|
step: string;
|
||||||
|
render_progress: number;
|
||||||
|
preview_b64: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App-wide minimal render progress pill. Mounted globally for authenticated users;
|
||||||
|
* polls /api/render/active and shows a small floating indicator whenever a render
|
||||||
|
* is in flight, on ANY page. Click → the full-screen render page for that project.
|
||||||
|
*
|
||||||
|
* Self-hides on the render page itself (which already shows full progress) and when
|
||||||
|
* there is no active render.
|
||||||
|
*/
|
||||||
|
export function GlobalRenderProgress({ authed }: { authed: boolean }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const [active, setActive] = useState<ActiveRender | null>(null);
|
||||||
|
const [dismissed, setDismissed] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Don't show on the render page itself.
|
||||||
|
const onRenderPage = /\/studio\/render\//.test(pathname);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authed) return;
|
||||||
|
let stop = false;
|
||||||
|
|
||||||
|
const poll = async () => {
|
||||||
|
if (document.visibilityState === "hidden") return;
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/render/active", { cache: "no-store" });
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = (await res.json()) as { active?: ActiveRender[] };
|
||||||
|
if (stop) return;
|
||||||
|
setActive(data.active?.[0] ?? null);
|
||||||
|
} catch {
|
||||||
|
/* ignore transient errors */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
poll();
|
||||||
|
const id = window.setInterval(poll, 4000);
|
||||||
|
return () => {
|
||||||
|
stop = true;
|
||||||
|
window.clearInterval(id);
|
||||||
|
};
|
||||||
|
}, [authed]);
|
||||||
|
|
||||||
|
if (!authed || !active || onRenderPage) return null;
|
||||||
|
if (dismissed === active.id) return null;
|
||||||
|
|
||||||
|
const pct = Math.max(0, Math.min(100, active.render_progress ?? 0));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.push(`/studio/render/${active.saved_project_id}`)}
|
||||||
|
dir="rtl"
|
||||||
|
className="fixed bottom-4 end-4 z-[70] flex w-64 items-center gap-3 rounded-xl border border-[#262b40] bg-[#0c0e1a]/95 p-3 text-start shadow-2xl backdrop-blur transition-colors hover:border-primary-500"
|
||||||
|
aria-label="مشاهده پیشرفت رندر"
|
||||||
|
>
|
||||||
|
{/* Thumbnail / spinner */}
|
||||||
|
<div className="relative h-11 w-11 shrink-0 overflow-hidden rounded-lg bg-[#070811]">
|
||||||
|
{active.preview_b64 ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={`data:image/png;base64,${active.preview_b64}`}
|
||||||
|
alt=""
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
|
<Film className="h-5 w-5 text-gray-600" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Text + progress */}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin text-primary-400" />
|
||||||
|
<span className="truncate text-xs font-medium text-gray-200">
|
||||||
|
{active.name?.trim() || "در حال رندر"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 flex items-center gap-2">
|
||||||
|
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-white/10">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-primary-500 transition-all duration-500"
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] tabular-nums text-gray-400">{pct}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dismiss (hides until job id changes) */}
|
||||||
|
<span
|
||||||
|
role="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setDismissed(active.id);
|
||||||
|
}}
|
||||||
|
className="shrink-0 rounded p-1 text-gray-600 hover:text-gray-300"
|
||||||
|
aria-label="پنهان کردن"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,325 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { Download, Link2, Loader2, RefreshCw } from "lucide-react";
|
|
||||||
|
|
||||||
import { apiFetch } from "@/lib/api/fetch";
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import type { RenderExportPreset } from "@/lib/render-presets";
|
|
||||||
import { RENDER_EXPORT_PRESETS } from "@/lib/render-presets";
|
|
||||||
import type { RenderSettings } from "@/lib/render-schemas";
|
|
||||||
import type { Scene } from "@/lib/studio-types";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
interface RenderModalProps {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
projectId: string;
|
|
||||||
scenes: Scene[];
|
|
||||||
preset?: RenderExportPreset | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
type JobStatus = "idle" | "submitting" | "polling" | "completed" | "failed";
|
|
||||||
|
|
||||||
interface StatusResponse {
|
|
||||||
status: string;
|
|
||||||
progress: number;
|
|
||||||
outputUrl: string | null;
|
|
||||||
progressMessage?: string | null;
|
|
||||||
errorMessage?: string | null;
|
|
||||||
/** Base64-encoded PNG preview frame from the node agent. */
|
|
||||||
previewB64?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const RESOLUTIONS: RenderSettings["resolution"][] = ["720p", "1080p", "4K"];
|
|
||||||
const FPS_OPTIONS: RenderSettings["fps"][] = [24, 30, 60];
|
|
||||||
|
|
||||||
export function RenderModal({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
projectId,
|
|
||||||
scenes,
|
|
||||||
preset = null,
|
|
||||||
}: RenderModalProps) {
|
|
||||||
const t = useTranslations("auto.componentsStudioRenderModal");
|
|
||||||
const [resolution, setResolution] =
|
|
||||||
useState<RenderSettings["resolution"]>("1080p");
|
|
||||||
const [fps, setFps] = useState<RenderSettings["fps"]>(30);
|
|
||||||
const [presetLabel, setPresetLabel] = useState<string | null>(null);
|
|
||||||
const [jobStatus, setJobStatus] = useState<JobStatus>("idle");
|
|
||||||
const [jobId, setJobId] = useState<string | null>(null);
|
|
||||||
const [progress, setProgress] = useState(0);
|
|
||||||
const [progressMessage, setProgressMessage] = useState("");
|
|
||||||
const [outputUrl, setOutputUrl] = useState<string | null>(null);
|
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
||||||
const [previewB64, setPreviewB64] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const reset = useCallback(() => {
|
|
||||||
setJobStatus("idle");
|
|
||||||
setJobId(null);
|
|
||||||
setProgress(0);
|
|
||||||
setProgressMessage("");
|
|
||||||
setOutputUrl(null);
|
|
||||||
setErrorMessage(null);
|
|
||||||
setPreviewB64(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) reset();
|
|
||||||
}, [open, reset]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open || !preset) return;
|
|
||||||
const config = RENDER_EXPORT_PRESETS[preset];
|
|
||||||
setResolution(config.settings.resolution);
|
|
||||||
setFps(config.settings.fps);
|
|
||||||
setPresetLabel(config.label);
|
|
||||||
}, [open, preset]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (jobStatus !== "polling" || !jobId) return;
|
|
||||||
|
|
||||||
const poll = async () => {
|
|
||||||
try {
|
|
||||||
const response = await apiFetch(`/api/render/${jobId}/status`);
|
|
||||||
const data = (await response.json()) as StatusResponse;
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
setJobStatus("failed");
|
|
||||||
setErrorMessage(t("errorFetchStatus"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setProgress(data.progress ?? 0);
|
|
||||||
setProgressMessage(
|
|
||||||
data.progressMessage ?? t("renderingProgress", { progress: data.progress })
|
|
||||||
);
|
|
||||||
if (data.previewB64) setPreviewB64(data.previewB64);
|
|
||||||
|
|
||||||
if (data.status === "completed" && data.outputUrl) {
|
|
||||||
setOutputUrl(data.outputUrl);
|
|
||||||
setJobStatus("completed");
|
|
||||||
setProgress(100);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.status === "failed") {
|
|
||||||
setJobStatus("failed");
|
|
||||||
setErrorMessage(data.errorMessage ?? t("errorRenderFailed"));
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setJobStatus("failed");
|
|
||||||
setErrorMessage(t("errorNetworkPolling"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
poll();
|
|
||||||
const intervalId = window.setInterval(poll, 3000);
|
|
||||||
return () => window.clearInterval(intervalId);
|
|
||||||
}, [jobStatus, jobId, t]);
|
|
||||||
|
|
||||||
const startRender = async () => {
|
|
||||||
setJobStatus("submitting");
|
|
||||||
setErrorMessage(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await apiFetch("/api/render", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
projectId,
|
|
||||||
settings: {
|
|
||||||
resolution,
|
|
||||||
format: "mp4" as const,
|
|
||||||
fps,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = (await response.json()) as {
|
|
||||||
jobId?: string;
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!response.ok || !data.jobId) {
|
|
||||||
setJobStatus("failed");
|
|
||||||
setErrorMessage(data.error ?? t("errorStartRender"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setJobId(data.jobId);
|
|
||||||
setJobStatus("polling");
|
|
||||||
setProgress(0);
|
|
||||||
setProgressMessage(t("queued"));
|
|
||||||
} catch {
|
|
||||||
setJobStatus("failed");
|
|
||||||
setErrorMessage(t("errorReachApi"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const isBusy = jobStatus === "submitting" || jobStatus === "polling";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{presetLabel ?? t("dialogTitle")}</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{preset
|
|
||||||
? RENDER_EXPORT_PRESETS[preset].description
|
|
||||||
: t("dialogDescription")}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{jobStatus === "completed" && outputUrl ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<p className="text-sm text-green-400">{t("videoReady")}</p>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<a
|
|
||||||
href={outputUrl}
|
|
||||||
download
|
|
||||||
className="inline-flex items-center justify-center gap-2 rounded-lg bg-primary-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-primary-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4c6ef5]"
|
|
||||||
>
|
|
||||||
<Download className="h-4 w-4" />
|
|
||||||
{t("downloadMp4")}
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href={outputUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline-flex items-center justify-center gap-2 rounded-lg border border-[#2a2d3e] px-4 py-2.5 text-sm text-gray-200 hover:bg-[#1f2234] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4c6ef5]"
|
|
||||||
>
|
|
||||||
<Link2 className="h-4 w-4" />
|
|
||||||
{t("shareLink")}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="w-full border-[#2a2d3e]"
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
>
|
|
||||||
{t("close")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : jobStatus === "failed" ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<p className="rounded-lg border border-red-900/50 bg-red-950/40 px-3 py-2 text-sm text-red-300">
|
|
||||||
{errorMessage ?? t("errorGeneric")}
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
className="w-full bg-primary-600 hover:bg-primary-700"
|
|
||||||
onClick={startRender}
|
|
||||||
>
|
|
||||||
<RefreshCw className="h-4 w-4" />
|
|
||||||
{t("retry")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : isBusy ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Live preview frame from node agent */}
|
|
||||||
<div className="relative overflow-hidden rounded-lg bg-[#0f111e] aspect-video">
|
|
||||||
{previewB64 ? (
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img
|
|
||||||
src={`data:image/png;base64,${previewB64}`}
|
|
||||||
alt={t("previewAlt")}
|
|
||||||
className="h-full w-full object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-primary-400/40" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* Step badge */}
|
|
||||||
<div className="absolute bottom-2 right-2 rounded-md bg-black/60 px-2 py-0.5 text-[10px] font-medium text-gray-300 backdrop-blur-sm">
|
|
||||||
{progressMessage || t("rendering")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="mb-1 flex justify-between text-xs text-gray-500">
|
|
||||||
<span>{t("progress")}</span>
|
|
||||||
<span>{progress}%</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-2 overflow-hidden rounded-full bg-[#1a1d2e]">
|
|
||||||
<div
|
|
||||||
className="h-full rounded-full bg-primary-600 transition-all duration-300"
|
|
||||||
style={{ width: `${progress}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<p className="mb-2 text-xs font-medium text-gray-400">
|
|
||||||
{t("resolution")}
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{RESOLUTIONS.map((item) => (
|
|
||||||
<button
|
|
||||||
key={item}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setResolution(item)}
|
|
||||||
className={cn(
|
|
||||||
"flex-1 rounded-lg border py-2 text-xs font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4c6ef5]",
|
|
||||||
resolution === item
|
|
||||||
? "border-primary-500 bg-primary-600/20 text-white"
|
|
||||||
: "border-[#2a2d3e] text-[#8b91a7] hover:border-[#3d4260]"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="mb-2 text-xs font-medium text-gray-400">{t("format")}</p>
|
|
||||||
<div className="rounded-lg border border-primary-500 bg-primary-600/20 px-3 py-2 text-center text-xs font-medium text-white">
|
|
||||||
MP4
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="mb-2 text-xs font-medium text-gray-400">{t("fps")}</p>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{FPS_OPTIONS.map((item) => (
|
|
||||||
<button
|
|
||||||
key={item}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setFps(item)}
|
|
||||||
className={cn(
|
|
||||||
"flex-1 rounded-lg border py-2 text-xs font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4c6ef5]",
|
|
||||||
fps === item
|
|
||||||
? "border-primary-500 bg-primary-600/20 text-white"
|
|
||||||
: "border-[#2a2d3e] text-[#8b91a7] hover:border-[#3d4260]"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
className="w-full bg-primary-600 hover:bg-primary-700"
|
|
||||||
onClick={startRender}
|
|
||||||
disabled={scenes.length === 0}
|
|
||||||
>
|
|
||||||
{t("startRendering")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
Camera,
|
Camera,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
Undo2,
|
Undo2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { RenderModal } from "@/components/studio/RenderModal";
|
|
||||||
import { StudioToolbar } from "@/components/studio/StudioToolbar";
|
import { StudioToolbar } from "@/components/studio/StudioToolbar";
|
||||||
import { StudioTopBarSaveBadge } from "@/components/studio/video/StudioTopBarSaveBadge";
|
import { StudioTopBarSaveBadge } from "@/components/studio/video/StudioTopBarSaveBadge";
|
||||||
import { StudioTopBarTextControls } from "@/components/studio/video/StudioTopBarTextControls";
|
import { StudioTopBarTextControls } from "@/components/studio/video/StudioTopBarTextControls";
|
||||||
@@ -58,17 +57,14 @@ export function StudioTopBar({
|
|||||||
const isPlaying = useStudioStore((state) => state.isPlaying);
|
const isPlaying = useStudioStore((state) => state.isPlaying);
|
||||||
const startPlayback = useStudioStore((state) => state.startPlayback);
|
const startPlayback = useStudioStore((state) => state.startPlayback);
|
||||||
const stopPlayback = useStudioStore((state) => state.stopPlayback);
|
const stopPlayback = useStudioStore((state) => state.stopPlayback);
|
||||||
const scenes = useStudioStore((state) => state.scenes);
|
|
||||||
const selectedLayer = useStudioStore((state) => getSelectedLayer(state));
|
const selectedLayer = useStudioStore((state) => getSelectedLayer(state));
|
||||||
|
|
||||||
const [renderOpen, setRenderOpen] = useState(false);
|
const router = useRouter();
|
||||||
const [renderPreset, setRenderPreset] = useState<RenderExportPreset | null>(
|
|
||||||
null
|
|
||||||
);
|
|
||||||
|
|
||||||
|
// Render is now a full-screen page (not a modal). Navigate with the chosen
|
||||||
|
// export preset; the page handles config → progress → download.
|
||||||
const openExport = (preset: RenderExportPreset) => {
|
const openExport = (preset: RenderExportPreset) => {
|
||||||
setRenderPreset(preset);
|
router.push(`/studio/render/${projectId}?preset=${preset}`);
|
||||||
setRenderOpen(true);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSnapshot = () => {
|
const handleSnapshot = () => {
|
||||||
@@ -212,17 +208,6 @@ export function StudioTopBar({
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<RenderModal
|
|
||||||
open={renderOpen}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
setRenderOpen(open);
|
|
||||||
if (!open) setRenderPreset(null);
|
|
||||||
}}
|
|
||||||
projectId={projectId}
|
|
||||||
scenes={scenes}
|
|
||||||
preset={renderPreset}
|
|
||||||
/>
|
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Download, Loader2 } from "lucide-react";
|
import { useState } from "react";
|
||||||
|
import { CloudUpload, Download, Loader2 } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { toast } from "@/components/ui/use-toast";
|
||||||
import type { ExportFormat } from "@/lib/trimmer-types";
|
import type { ExportFormat } from "@/lib/trimmer-types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -29,6 +31,29 @@ export function TrimmerExportSection({
|
|||||||
onProcess,
|
onProcess,
|
||||||
}: TrimmerExportSectionProps) {
|
}: TrimmerExportSectionProps) {
|
||||||
const t = useTranslations("auto.componentsTrimmerTrimmerExportSection");
|
const t = useTranslations("auto.componentsTrimmerTrimmerExportSection");
|
||||||
|
const [savingCloud, setSavingCloud] = useState(false);
|
||||||
|
|
||||||
|
const handleSaveToCloud = async () => {
|
||||||
|
if (!outputUrl) return;
|
||||||
|
setSavingCloud(true);
|
||||||
|
try {
|
||||||
|
const blob = await fetch(outputUrl).then((r) => r.blob());
|
||||||
|
const fd = new FormData();
|
||||||
|
const mime = exportFormat === "webm" ? "video/webm" : "video/mp4";
|
||||||
|
fd.append(
|
||||||
|
"file",
|
||||||
|
new File([blob], `trimmed-${Date.now()}.${exportFormat}`, { type: mime })
|
||||||
|
);
|
||||||
|
const res = await fetch("/api/files/upload", { method: "POST", body: fd });
|
||||||
|
const data = (await res.json().catch(() => null)) as { url?: string } | null;
|
||||||
|
toast({ title: res.ok && data?.url ? t("savedToCloud") : t("saveToCloudFailed") });
|
||||||
|
} catch {
|
||||||
|
toast({ title: t("saveToCloudFailed") });
|
||||||
|
} finally {
|
||||||
|
setSavingCloud(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded-xl border border-gray-800 bg-gray-900 p-6 shadow-sm">
|
<section className="rounded-xl border border-gray-800 bg-gray-900 p-6 shadow-sm">
|
||||||
<h2 className="mb-4 text-sm font-semibold text-white">{t("heading")}</h2>
|
<h2 className="mb-4 text-sm font-semibold text-white">{t("heading")}</h2>
|
||||||
@@ -104,6 +129,20 @@ export function TrimmerExportSection({
|
|||||||
<Download className="h-4 w-4" aria-hidden />
|
<Download className="h-4 w-4" aria-hidden />
|
||||||
{t("download", { format: exportFormat.toUpperCase() })}
|
{t("download", { format: exportFormat.toUpperCase() })}
|
||||||
</a>
|
</a>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full border-gray-700 bg-gray-800 text-white hover:bg-gray-700"
|
||||||
|
onClick={handleSaveToCloud}
|
||||||
|
disabled={savingCloud}
|
||||||
|
>
|
||||||
|
{savingCloud ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<CloudUpload className="h-4 w-4" aria-hidden />
|
||||||
|
)}
|
||||||
|
{savingCloud ? t("savingToCloud") : t("saveToCloud")}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
+45
-2
@@ -48,11 +48,54 @@ export function humanSize(n?: number): string {
|
|||||||
return `${v.toFixed(v < 10 && i > 0 ? 1 : 0)} ${u[i]}`;
|
return `${v.toFixed(v < 10 && i > 0 ? 1 : 0)} ${u[i]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fetch files from the admin gateway with optional search + type filter. */
|
/** Fetch files from the admin gateway with optional search + type + folder filter. */
|
||||||
export async function fetchFiles(search: string, fileType: string, page = 1, pageSize = 60): Promise<FileItem[]> {
|
export async function fetchFiles(
|
||||||
|
search: string,
|
||||||
|
fileType: string,
|
||||||
|
folderId: string | null = null,
|
||||||
|
page = 1,
|
||||||
|
pageSize = 60
|
||||||
|
): Promise<FileItem[]> {
|
||||||
const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize), page_size: String(pageSize) });
|
const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize), page_size: String(pageSize) });
|
||||||
if (search) qs.set("search", search);
|
if (search) qs.set("search", search);
|
||||||
if (fileType) qs.set("file_type", fileType);
|
if (fileType) qs.set("file_type", fileType);
|
||||||
|
if (folderId) qs.set("folder_id", folderId);
|
||||||
const r = await fetch(`/api/admin/resource/files?${qs.toString()}`, { cache: "no-store" }).then((x) => x.json()).catch(() => null);
|
const r = await fetch(`/api/admin/resource/files?${qs.toString()}`, { cache: "no-store" }).then((x) => x.json()).catch(() => null);
|
||||||
return r?.items ?? r?.data ?? (Array.isArray(r) ? r : []);
|
return r?.items ?? r?.data ?? (Array.isArray(r) ? r : []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Folders ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface FolderItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
folder_type?: string;
|
||||||
|
parent_folder_id?: string | null;
|
||||||
|
file_count?: number;
|
||||||
|
total_size_bytes?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** List folders under a parent (null = root). */
|
||||||
|
export async function fetchFolders(parentId: string | null = null): Promise<FolderItem[]> {
|
||||||
|
const qs = parentId ? `?parent_id=${parentId}` : "";
|
||||||
|
const r = await fetch(`/api/admin/resource/folders${qs}`, { cache: "no-store" })
|
||||||
|
.then((x) => x.json())
|
||||||
|
.catch(() => null);
|
||||||
|
return Array.isArray(r) ? r : (r?.items ?? r?.data ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a folder (optionally nested under a parent). */
|
||||||
|
export async function createFolder(name: string, parentId: string | null = null): Promise<boolean> {
|
||||||
|
const res = await fetch(`/api/admin/resource/folders`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name, parent_folder_id: parentId }),
|
||||||
|
});
|
||||||
|
return res.ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete a folder by id. */
|
||||||
|
export async function deleteFolder(id: string): Promise<boolean> {
|
||||||
|
const res = await fetch(`/api/admin/resource/folders/${id}`, { method: "DELETE" });
|
||||||
|
return res.ok;
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,3 +25,46 @@ export function downloadStageImage(
|
|||||||
link.href = dataUrl;
|
link.href = dataUrl;
|
||||||
link.click();
|
link.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Render the stage to a Blob at 2× pixel ratio for upload. */
|
||||||
|
export async function stageToBlob(
|
||||||
|
stage: Konva.Stage,
|
||||||
|
format: ExportImageFormat,
|
||||||
|
quality: number
|
||||||
|
): Promise<Blob | null> {
|
||||||
|
const mimeType =
|
||||||
|
format === "png"
|
||||||
|
? "image/png"
|
||||||
|
: format === "jpg"
|
||||||
|
? "image/jpeg"
|
||||||
|
: "image/webp";
|
||||||
|
|
||||||
|
const dataUrl = stage.toDataURL({
|
||||||
|
pixelRatio: 2,
|
||||||
|
mimeType,
|
||||||
|
quality: format === "png" ? 1 : quality / 100,
|
||||||
|
});
|
||||||
|
const res = await fetch(dataUrl);
|
||||||
|
return res.blob();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the stage and upload it to the user's cloud library (user-uploads bucket).
|
||||||
|
* Returns the public URL on success, or null on failure.
|
||||||
|
*/
|
||||||
|
export async function saveStageToCloud(
|
||||||
|
stage: Konva.Stage,
|
||||||
|
format: ExportImageFormat,
|
||||||
|
quality: number
|
||||||
|
): Promise<string | null> {
|
||||||
|
const blob = await stageToBlob(stage, format, quality);
|
||||||
|
if (!blob) return null;
|
||||||
|
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", blob, `design-${Date.now()}.${format}`);
|
||||||
|
|
||||||
|
const res = await fetch("/api/files/upload", { method: "POST", body: fd });
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const data = (await res.json().catch(() => null)) as { url?: string } | null;
|
||||||
|
return data?.url ?? null;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user