Files
flatrender/services/render/internal/handlers/renders.go
T
soroush.asadi 6d79ddb8d1 feat(render): real progress %, ETA, and frequent preview during AE renders
The render page already displayed progress/ETA/preview — but the node agent never
fed real data: aeRender used fake +5%/10s increments, discarded aerender stdout,
and pushed a preview only every 30s. (Plus the deployed agent predated even the
progress-reporting wiring.)

node-agent (aeRender):
- Capture aerender stdout; parse "(N):" current frame + "N frames"/"to N" total.
- Real percentage when total is known (5–90%, headroom for transcode/upload),
  else a smooth time-asymptotic estimate that never sticks — message shows the
  live frame number either way.
- Push a preview frame ~every 8s (was 30s) so the box fills in quickly.

render-svc:
- GET /v1/renders/:id/progress now computes eta_seconds from started_at + progress
  (linear extrapolation) instead of returning null.

frontend:
- Thread eta_seconds → status route → render page; page prefers the server ETA and
  falls back to the client-observed rate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 01:18:54 +03:30

297 lines
9.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handlers
import (
"time"
"log"
"net/http"
"strconv"
"github.com/flatrender/render-svc/internal/db"
"github.com/flatrender/render-svc/internal/identityclient"
"github.com/flatrender/render-svc/internal/middleware"
"github.com/flatrender/render-svc/internal/models"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type RenderHandler struct {
store *db.Store
identity *identityclient.Client
}
func NewRenderHandler(store *db.Store, identity *identityclient.Client) *RenderHandler {
return &RenderHandler{store: store, identity: identity}
}
// GET /v1/renders
func (h *RenderHandler) List(c *gin.Context) {
userID := middleware.GetUserID(c)
status := c.Query("status")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
jobs, total, err := h.store.ListJobs(c.Request.Context(), userID, status, page, pageSize)
if err != nil {
c.JSON(http.StatusInternalServerError, models.APIError{Code: "internal_error", Message: err.Error()})
return
}
if jobs == nil {
jobs = []*models.RenderJob{}
}
c.JSON(http.StatusOK, models.PagedResponse[*models.RenderJob]{
Data: jobs,
Meta: models.PaginationMeta{
Page: page,
PageSize: pageSize,
Total: total,
HasMore: int64(page*pageSize) < total,
},
})
}
// 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
func (h *RenderHandler) Create(c *gin.Context) {
userID := middleware.GetUserID(c)
tenantID := middleware.GetTenantID(c)
var req models.RenderJobCreateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: err.Error()})
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).
allowed, err := h.identity.Consume(c.Request.Context(), userID)
if err != nil {
log.Printf("render-charge consume failed (allowing render): %v", err)
}
if !allowed {
c.JSON(http.StatusTooManyRequests, models.APIError{
Code: "daily_render_limit", Message: "سقف رندر روزانهٔ شما به پایان رسیده است.",
})
return
}
job, err := h.store.CreateJob(c.Request.Context(), userID, tenantID, &req)
if err != nil {
// Creation failed after consuming — return the charge.
_ = h.identity.Refund(c.Request.Context(), userID)
c.JSON(http.StatusInternalServerError, models.APIError{Code: "internal_error", Message: err.Error()})
return
}
c.JSON(http.StatusCreated, job)
}
// GET /v1/renders/:job_id
func (h *RenderHandler) Get(c *gin.Context) {
userID := middleware.GetUserID(c)
jobID, err := uuid.Parse(c.Param("job_id"))
if err != nil {
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "invalid job_id"})
return
}
job, err := h.store.GetJobByID(c.Request.Context(), jobID, userID)
if err != nil {
c.JSON(http.StatusNotFound, models.APIError{Code: "not_found", Message: err.Error()})
return
}
frames, _ := h.store.ListFrameJobs(c.Request.Context(), jobID)
if frames == nil {
frames = []*models.FrameJob{}
}
c.JSON(http.StatusOK, gin.H{
"id": job.ID,
"saved_project_id": job.SavedProjectID,
"name": job.Name,
"step": job.Step,
"render_progress": job.RenderProgress,
"priority_queue": job.PriorityQueue,
"price_type": job.PriceType,
"paid_price_minor": job.PaidPriceMinor,
"quality": job.Quality,
"resolution": job.Resolution,
"frame_rate": job.FrameRate,
"duration_sec": job.DurationSec,
"has_voiceover": job.HasVoiceover,
"image_preview_b64": job.ImagePreviewB64,
"failed_message": job.FailedMessage,
"export_id": job.ExportID,
"queued_at": job.QueuedAt,
"started_at": job.StartedAt,
"completed_at": job.CompletedAt,
"retry_count": job.RetryCount,
"repair_attempts": job.RepairAttempts,
"frame_jobs": frames,
})
}
// POST /v1/renders/:job_id/cancel
func (h *RenderHandler) Cancel(c *gin.Context) {
userID := middleware.GetUserID(c)
jobID, err := uuid.Parse(c.Param("job_id"))
if err != nil {
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "invalid job_id"})
return
}
cancelled, err := h.store.CancelJob(c.Request.Context(), jobID, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, models.APIError{Code: "internal_error", Message: err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"cancelled": cancelled,
"refund_amount_minor": 0,
})
}
// POST /v1/renders/:job_id/stop — admin: stop any user's in-progress job
func (h *RenderHandler) Stop(c *gin.Context) {
jobID, err := uuid.Parse(c.Param("job_id"))
if err != nil {
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "invalid job_id"})
return
}
stopped, ownerID, err := h.store.StopJob(c.Request.Context(), jobID)
if err != nil {
c.JSON(http.StatusInternalServerError, models.APIError{Code: "internal_error", Message: err.Error()})
return
}
// Admin stop → refund the user's render charge (not their fault).
if stopped && ownerID != uuid.Nil {
if err := h.identity.Refund(c.Request.Context(), ownerID); err != nil {
log.Printf("render-charge refund failed for %s: %v", ownerID, err)
}
}
c.JSON(http.StatusOK, gin.H{"stopped": stopped})
}
// POST /v1/renders/:job_id/retry
func (h *RenderHandler) Retry(c *gin.Context) {
userID := middleware.GetUserID(c)
tenantID := middleware.GetTenantID(c)
jobID, err := uuid.Parse(c.Param("job_id"))
if err != nil {
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "invalid job_id"})
return
}
original, err := h.store.GetJobByID(c.Request.Context(), jobID, userID)
if err != nil {
c.JSON(http.StatusNotFound, models.APIError{Code: "not_found", Message: err.Error()})
return
}
// Create a new job cloning the original config
req := &models.RenderJobCreateRequest{
SavedProjectID: original.SavedProjectID,
Quality: original.Quality,
Resolution: original.Resolution,
FrameRate: &original.FrameRate,
}
newJob, err := h.store.CreateJob(c.Request.Context(), userID, tenantID, req)
if err != nil {
c.JSON(http.StatusInternalServerError, models.APIError{Code: "internal_error", Message: err.Error()})
return
}
c.JSON(http.StatusCreated, newJob)
}
// GET /v1/renders/:job_id/progress
func (h *RenderHandler) Progress(c *gin.Context) {
userID := middleware.GetUserID(c)
jobID, err := uuid.Parse(c.Param("job_id"))
if err != nil {
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "invalid job_id"})
return
}
job, err := h.store.GetJobByID(c.Request.Context(), jobID, userID)
if err != nil {
c.JSON(http.StatusNotFound, models.APIError{Code: "not_found", Message: err.Error()})
return
}
// Estimate remaining seconds from elapsed time and progress (linear extrapolation).
// Only once the job is actually progressing (599%) and we know when it started.
var etaSeconds *int
if job.StartedAt != nil && job.RenderProgress >= 5 && job.RenderProgress < 100 {
elapsed := time.Since(*job.StartedAt).Seconds()
if elapsed > 0 {
remaining := elapsed * float64(100-job.RenderProgress) / float64(job.RenderProgress)
if remaining < 0 {
remaining = 0
}
eta := int(remaining)
etaSeconds = &eta
}
}
c.JSON(http.StatusOK, gin.H{
"job_id": job.ID,
"step": job.Step,
"progress": job.RenderProgress,
"current_frame": nil,
"total_frames": nil,
"eta_seconds": etaSeconds,
"preview_b64": job.ImagePreviewB64,
"active_nodes": job.CurrentActiveNodes,
"message": job.FailedMessage,
})
}
// GET /v1/renders/:job_id/logs
func (h *RenderHandler) Logs(c *gin.Context) {
// Logs are stored externally (MinIO/ELK). Return empty for now — node agents push logs elsewhere.
c.JSON(http.StatusOK, gin.H{"logs": []any{}})
}