pictify 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 61ccf483d399d895aed8467dd41ba36464a70b237c370cd03eb4a14911602a17
4
- data.tar.gz: 168e24019d0d4e03c56866316a1557e397ed3c643d570ba6a9a0df49ddd2f601
3
+ metadata.gz: 7f414dc090d1668bcca2f5b240dbfdd0b1b1c1463dfddfe332f40dd1acd0ad2a
4
+ data.tar.gz: 1404b77c7a5acc442a1345b62bcb4cafc7140799f748ae6ee277b3df992f4651
5
5
  SHA512:
6
- metadata.gz: 128158b9198f1a6388efad4a18152d0d563d9865b1ac306da1a280e8ad4e89882191cf912eb9a59d3ce4e1485e838861146de50a28ee55b2a52258dc1feb2c6e
7
- data.tar.gz: f50e95a9dbb6e177021d512696be627a986f40e7b519712e2b994f2a920da6dae30832ccd6b8274dbb159e91308503c2de6d21ac4f94fd29a34535bfeb027d64
6
+ metadata.gz: eacf2d2150cc55bb131fface0a92de28e04bab52df5873a8765508e96d79d6a08739e38cc1bf34b3a226c209e3c75afbc955a712863c15ded0b035185ae9434e
7
+ data.tar.gz: a82cc93aafbf0b99985550d2dc476e3a33d7a38faf848a4d09b82bffc546211b5dfcd87deeba8d00c46e2467679e741195dbcff4342e558ef91dc44d0ec7b883
data/CHANGELOG.md CHANGED
@@ -5,6 +5,31 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.1.0] - 2026-08-03
9
+
10
+ ### Added
11
+
12
+ - Video template surface (`/video/templates`):
13
+ - `list_video_templates` — `GET /video/templates`, unwraps `{ templates }`
14
+ into an array of `VideoTemplate`.
15
+ - `get_video_template_variables(template_id)` —
16
+ `GET /video/templates/:uid/variables`, returns `VideoTemplateVariables`.
17
+ - `render_video(template_id, variables:, format:, timeout:)` —
18
+ `POST /video/templates/:uid/render`, waits for the finished MP4/GIF and
19
+ returns `VideoRenderResult` (`url`, `duration_in_frames`, `format`).
20
+ - `generate_video_template(prompt:, width:, height:, duration_seconds:, brand_color:, timeout:)`
21
+ — `POST /video/templates/generate`, returns `GenerateVideoTemplateResult`
22
+ (`template`, `preview_url`).
23
+ - `create_video_template(name:, tsx:, width:, height:, fps:, duration_seconds:, timeout:)`
24
+ — `POST /video/templates` with `kind: "tsx"`, `status: "draft"` and
25
+ `durationInFrames = (duration_seconds * fps).round`. Invalid tsx fails the
26
+ server-side compile gate with a 422 `RenderError` carrying the compiler
27
+ errors and saves nothing (retries are safe).
28
+ - Per-request timeout override in the HTTP layer: video renders run minutes,
29
+ so the video methods default to their own timeouts (render 300s,
30
+ generate/create 180s) without changing the global `timeout` default.
31
+ `TimeoutError#timeout` now reports the effective per-call timeout.
32
+
8
33
  ## [1.0.0] - 2026-06-08
9
34
 
10
35
  ### Changed (BREAKING)
data/README.md CHANGED
@@ -223,6 +223,62 @@ template = client.create_template(
223
223
  puts template.uid
224
224
  ```
225
225
 
226
+ ## Video Templates
227
+
228
+ Video templates render MP4s (or animated GIFs) server-side. Render requests
229
+ wait for the finished file and can legitimately run minutes — the video methods
230
+ use their own per-call timeouts (render 300s, generate/create 180s) instead of
231
+ the global `timeout`, and every method accepts a `timeout:` override.
232
+
233
+ ```ruby
234
+ # List video templates — GET /video/templates
235
+ templates = client.list_video_templates
236
+ templates.each { |t| puts "#{t.uid}: #{t.name} (#{t.kind}, #{t.duration_in_frames} frames)" }
237
+
238
+ # What variables can I set? — GET /video/templates/:uid/variables
239
+ vars = client.get_video_template_variables("video-template-uid")
240
+ vars.variables.each { |v| puts " - #{v.name} (#{v.type}, default: #{v.default_value})" }
241
+
242
+ # Render to MP4 (or GIF) — POST /video/templates/:uid/render
243
+ # Omitted variables render their defaults. Each render consumes one video credit.
244
+ video = client.render_video(
245
+ "video-template-uid",
246
+ variables: { title: "Welcome, Maya!" },
247
+ format: :mp4 # :mp4 (default) or :gif — GIF is capped at 15fps / 720px wide
248
+ )
249
+ puts video.url
250
+ puts video.duration_in_frames
251
+ puts video.format
252
+
253
+ # Generate a template from a prompt with AI — POST /video/templates/generate
254
+ # Takes 30-60 seconds; metered as one render.
255
+ result = client.generate_video_template(
256
+ prompt: "A 8s product launch teaser for a coffee brand, warm and bold",
257
+ width: 1080,
258
+ height: 1080,
259
+ duration_seconds: 8,
260
+ brand_color: "#ff0055" # optional
261
+ )
262
+ puts result.template.uid
263
+ puts result.preview_url
264
+
265
+ # Upload a Remotion scene you wrote — POST /video/templates
266
+ # The tsx must export a zod `schema` (flat fields with defaults — they become
267
+ # the template's variables) and a default React component; imports limited to
268
+ # remotion, react and zod. It passes a compile gate BEFORE anything is saved:
269
+ # invalid tsx raises a 422 Pictify::RenderError carrying the compiler errors
270
+ # (err.errors) and creates NOTHING — retrying is always safe.
271
+ template = client.create_video_template(
272
+ name: "My Scene",
273
+ tsx: tsx_source,
274
+ width: 1080,
275
+ height: 1080,
276
+ fps: 30,
277
+ duration_seconds: 8 # durationInFrames is computed as (seconds * fps).round
278
+ )
279
+ puts template.uid
280
+ ```
281
+
226
282
  ## Error Handling
227
283
 
228
284
  ```ruby
@@ -25,6 +25,11 @@ module Pictify
25
25
  DEFAULT_TIMEOUT = 30
26
26
  DEFAULT_MAX_RETRIES = 3
27
27
 
28
+ # Video renders legitimately run minutes; these per-call defaults override
29
+ # the global +timeout+ for the video endpoints only.
30
+ DEFAULT_VIDEO_RENDER_TIMEOUT = 300
31
+ DEFAULT_VIDEO_TEMPLATE_TIMEOUT = 180
32
+
28
33
  # @param api_key [String] Your Pictify API key
29
34
  # @param base_url [String] Custom API base URL
30
35
  # @param timeout [Integer] Request timeout in seconds
@@ -288,24 +293,171 @@ module Pictify
288
293
  Template.new(response["template"] || {})
289
294
  end
290
295
 
296
+ # ------------------------------------------------------------------------
297
+ # Video templates
298
+ # ------------------------------------------------------------------------
299
+
300
+ # List your video templates.
301
+ #
302
+ # +GET /video/templates+ — unwraps the +{ templates }+ envelope.
303
+ #
304
+ # @return [Array<VideoTemplate>]
305
+ def list_video_templates
306
+ response = request(:get, "video/templates")
307
+ (response["templates"] || []).map { |t| VideoTemplate.new(t) }
308
+ end
309
+
310
+ # A video template's variable definitions — what you can set when
311
+ # rendering it. Call before {#render_video} to know what to pass.
312
+ #
313
+ # +GET /video/templates/:uid/variables+.
314
+ #
315
+ # @param template_id [String] The video template UID
316
+ # @return [VideoTemplateVariables]
317
+ def get_video_template_variables(template_id)
318
+ response = request(:get, "video/templates/#{encode(template_id)}/variables")
319
+ VideoTemplateVariables.new(response)
320
+ end
321
+
322
+ # Render a video template to MP4 — or an animated GIF — with variables.
323
+ #
324
+ # +POST /video/templates/:uid/render+ — the request WAITS for the finished
325
+ # file (up to a few minutes) and returns its hosted URL. Each render
326
+ # consumes one video credit. Omitted variables render their defaults;
327
+ # unknown variable names fail with a 422 {RenderError}.
328
+ #
329
+ # @example
330
+ # video = client.render_video("abc123", variables: { title: "Welcome, Maya!" }, format: :gif)
331
+ # puts video.url
332
+ #
333
+ # @param template_id [String] The video template UID (see {#list_video_templates})
334
+ # @param variables [Hash] Variables to inject; omitted variables render their defaults
335
+ # @param format [Symbol, String] Output format (default mp4). +gif+ is a
336
+ # palette-optimised animated GIF capped at 15fps / 720px wide
337
+ # @param timeout [Integer, nil] Per-call timeout in seconds (default 300 —
338
+ # video renders take minutes; the global +timeout+ is not used here)
339
+ # @return [VideoRenderResult]
340
+ def render_video(template_id, variables: {}, format: "mp4", timeout: nil)
341
+ response = request(:post, "video/templates/#{encode(template_id)}/render", {
342
+ variables: variables || {},
343
+ format: (format || :mp4).to_s
344
+ }, nil, timeout: timeout || DEFAULT_VIDEO_RENDER_TIMEOUT)
345
+ VideoRenderResult.new(response)
346
+ end
347
+
348
+ # Generate a new video template from a prompt using AI.
349
+ #
350
+ # +POST /video/templates/generate+ — the service designs a motion brief,
351
+ # writes the scene as code, compiles it, renders preview frames and reviews
352
+ # them visually — then saves a draft template whose texts, colors and
353
+ # optional image are editable variables. Takes 30-60 seconds; metered as
354
+ # one render.
355
+ #
356
+ # @param prompt [String] What the video is for, with any mood/style
357
+ # guidance (max 2000 chars)
358
+ # @param width [Integer] Canvas width in pixels (default 1080)
359
+ # @param height [Integer] Canvas height in pixels (default 1080)
360
+ # @param duration_seconds [Numeric] Video length in seconds, 1-60 (default 8)
361
+ # @param brand_color [String, nil] Optional brand color (hex) to build the
362
+ # palette around
363
+ # @param timeout [Integer, nil] Per-call timeout in seconds (default 180)
364
+ # @return [GenerateVideoTemplateResult]
365
+ def generate_video_template(prompt:, width: 1080, height: 1080, duration_seconds: 8,
366
+ brand_color: nil, timeout: nil)
367
+ response = request(:post, "video/templates/generate", {
368
+ prompt: prompt,
369
+ width: width,
370
+ height: height,
371
+ durationSeconds: duration_seconds,
372
+ brandColor: brand_color
373
+ }, nil, timeout: timeout || DEFAULT_VIDEO_TEMPLATE_TIMEOUT)
374
+ GenerateVideoTemplateResult.new(response)
375
+ end
376
+
377
+ # Upload a Remotion scene you wrote as a new video template.
378
+ #
379
+ # +POST /video/templates+ — unwraps the +{ template }+ envelope. The SDK
380
+ # sends +kind: "tsx"+, +status: "draft"+ and computes
381
+ # +durationInFrames = (duration_seconds * fps).round+.
382
+ #
383
+ # The source passes a compile gate BEFORE anything is saved: invalid tsx
384
+ # rejects with a 422 {RenderError} carrying the compiler errors
385
+ # (+error.errors+) and creates NOTHING, so retrying cannot litter the
386
+ # account with broken templates.
387
+ #
388
+ # @param name [String] Template name shown in the dashboard
389
+ # @param tsx [String] The complete single-file Remotion scene source. Must
390
+ # export a zod +schema+ (flat fields with defaults — they become the
391
+ # template's variables) and a default React component; imports limited to
392
+ # +remotion+, +react+ and +zod+
393
+ # @param width [Integer] Canvas width in pixels (default 1080)
394
+ # @param height [Integer] Canvas height in pixels (default 1080)
395
+ # @param fps [Integer] Frames per second (default 30)
396
+ # @param duration_seconds [Numeric] Video length in seconds (default 8)
397
+ # @param timeout [Integer, nil] Per-call timeout in seconds (default 180 —
398
+ # the compile gate bundles the scene)
399
+ # @return [VideoTemplate]
400
+ def create_video_template(name:, tsx:, width: 1080, height: 1080, fps: 30,
401
+ duration_seconds: 8, timeout: nil)
402
+ response = request(:post, "video/templates", {
403
+ name: name,
404
+ kind: "tsx",
405
+ tsx: tsx,
406
+ width: width,
407
+ height: height,
408
+ fps: fps,
409
+ durationInFrames: (duration_seconds * fps).round,
410
+ status: "draft"
411
+ }, nil, timeout: timeout || DEFAULT_VIDEO_TEMPLATE_TIMEOUT)
412
+ VideoTemplate.new(response["template"] || {})
413
+ end
414
+
415
+ # Runs between the retry middleware and the adapter. The net_http adapter
416
+ # wraps +Net::OpenTimeout+ as +Faraday::ConnectionFailed+, which would slip
417
+ # a timeout past a retry exception list that (deliberately) excludes
418
+ # timeouts — so timeout-flavored connection failures are re-raised as
419
+ # +Faraday::TimeoutError+ before the retry middleware sees them.
420
+ class TimeoutClassifier < Faraday::Middleware
421
+ TIMEOUT_HINTS = ["timeout", "timed out", "execution expired"].freeze
422
+
423
+ def call(env)
424
+ @app.call(env)
425
+ rescue Faraday::ConnectionFailed => e
426
+ raise Faraday::TimeoutError, e if e.cause.is_a?(Timeout::Error) ||
427
+ TIMEOUT_HINTS.any? { |hint| e.message.to_s.downcase.include?(hint) }
428
+
429
+ raise
430
+ end
431
+ end
432
+
291
433
  private
292
434
 
293
435
  def encode(value)
294
- CGI.escape(value.to_s)
436
+ # escapeURIComponent, not escape: CGI.escape is form encoding (space
437
+ # becomes "+", which the server decodes as a literal plus in a path).
438
+ CGI.escapeURIComponent(value.to_s)
295
439
  end
296
440
 
297
441
  def connection
298
442
  @connection ||= Faraday.new(url: @base_url) do |f|
299
443
  f.request :retry,
300
444
  max: @max_retries,
301
- interval: 0.05,
445
+ interval: 1,
302
446
  backoff_factor: 2,
303
447
  retry_statuses: [500, 502, 503, 504],
304
- # Keep faraday-retry's defaults (which include RetriableResponse
305
- # required for retry_statuses to engage) and add network
306
- # failures so 5xx and connection drops retry, but 4xx never do.
307
- exceptions: Faraday::Retry::Middleware::DEFAULT_EXCEPTIONS + [Faraday::ConnectionFailed],
448
+ # An EXPLICIT exception list. faraday-retry's defaults include
449
+ # Faraday::TimeoutError and Timeout::Error, and with POST
450
+ # opted in a timing-out render was retried but generation
451
+ # endpoints are billed, non-idempotent POSTs and the server
452
+ # keeps rendering (and meters) after the client aborts, so a
453
+ # timed-out call could bill max_retries+1 credits. Timeouts
454
+ # must surface immediately (TimeoutClassifier below keeps
455
+ # them out of ConnectionFailed); genuine connection failures
456
+ # stay retryable, and RetriableResponse keeps retry_statuses
457
+ # engaged. 4xx never retry.
458
+ exceptions: [Faraday::ConnectionFailed, Faraday::RetriableResponse],
308
459
  methods: %i[get post put delete patch]
460
+ f.use TimeoutClassifier
309
461
  f.options.timeout = @timeout
310
462
  f.options.open_timeout = 10
311
463
  f.headers["Authorization"] = "Bearer #{@api_key}"
@@ -316,9 +468,14 @@ module Pictify
316
468
 
317
469
  # Make an authenticated JSON request. Nil body fields are stripped so the
318
470
  # backend applies its own defaults. HTTP errors are mapped to typed errors.
319
- def request(method, path, body = nil, params = nil)
471
+ #
472
+ # +timeout+ overrides the global read timeout for this request only (video
473
+ # renders legitimately run minutes); the client-wide default is untouched.
474
+ def request(method, path, body = nil, params = nil, timeout: nil)
475
+ effective_timeout = timeout || @timeout
320
476
  response = connection.send(method) do |req|
321
477
  req.url(path, params)
478
+ req.options.timeout = timeout if timeout
322
479
  req.body = JSON.generate(strip_nils(body)) if body
323
480
  end
324
481
 
@@ -329,10 +486,10 @@ module Pictify
329
486
  env = e.response
330
487
  raise Pictify.error_from_response(env.status, error_body(env.body))
331
488
  rescue Faraday::TimeoutError
332
- raise TimeoutError.new("Request timed out", timeout: @timeout)
489
+ raise TimeoutError.new("Request timed out", timeout: effective_timeout)
333
490
  rescue Faraday::ConnectionFailed => e
334
491
  if e.message.include?("timeout") || e.message.include?("timed out") || e.message.include?("execution expired")
335
- raise TimeoutError.new("Request timed out", timeout: @timeout)
492
+ raise TimeoutError.new("Request timed out", timeout: effective_timeout)
336
493
  end
337
494
 
338
495
  raise NetworkError.new(nil, original_error: e)
@@ -129,8 +129,9 @@ module Pictify
129
129
  #
130
130
  # The Pictify API has no unified error envelope: image/GIF endpoints return
131
131
  # +{ error, code }+ while template/CRUD endpoints return +{ message }+ or
132
- # +{ message, errors }+. Message precedence is +error+ then +message+ then a
133
- # fallback.
132
+ # +{ message, errors }+. Message precedence is +error+ then +message+ then
133
+ # the joined +errors+ strings (the video compile gate's 422 shape, where the
134
+ # strings are the fix instructions) then a fallback.
134
135
  #
135
136
  # Status mapping:
136
137
  # - 401 -> AuthenticationError
@@ -142,7 +143,14 @@ module Pictify
142
143
  # - 5xx -> ServerError
143
144
  def self.error_from_response(status_code, body)
144
145
  body ||= {}
145
- message = body["error"] || body["message"] || "An unexpected error occurred"
146
+ # The video compile gate returns 422 { errors: ["..."] } with no
147
+ # message/error key — those strings ARE the fix instructions, so they
148
+ # join into the message rather than leaving a generic fallback.
149
+ errors = body["errors"]
150
+ joined_errors = if errors.is_a?(Array) && !errors.empty? && errors.all? { |e| e.is_a?(String) }
151
+ errors.join("; ")
152
+ end
153
+ message = body["error"] || body["message"] || joined_errors || "An unexpected error occurred"
146
154
 
147
155
  case status_code
148
156
  when 401
data/lib/pictify/types.rb CHANGED
@@ -12,6 +12,12 @@ module Pictify
12
12
  # GIF quality presets accepted by the +/gif+ endpoint.
13
13
  GIF_QUALITIES = %i[low medium high].freeze
14
14
 
15
+ # Video output formats supported by video template renders.
16
+ #
17
+ # +gif+ produces a palette-optimised animated GIF capped at 15fps / 720px
18
+ # wide — the same render, encoded for places an MP4 cannot autoplay.
19
+ VIDEO_FORMATS = %i[mp4 gif].freeze
20
+
15
21
  # Result of an +/image+ render (+render_html+ / +render_url+).
16
22
  #
17
23
  # Maps the API response +{ url, id, createdAt }+.
@@ -253,4 +259,91 @@ module Pictify
253
259
  @pagination = data["pagination"] ? Pagination.new(data["pagination"]) : nil
254
260
  end
255
261
  end
262
+
263
+ # A video template returned by +list_video_templates+ / +create_video_template+
264
+ # (and nested in +generate_video_template+ results).
265
+ #
266
+ # +kind+ is +"timeline"+ (studio-built) or +"tsx"+ (Remotion scene) — both
267
+ # render identically. The API keys video templates by +uid+ and declares
268
+ # variables in +variableDefinitions+. Unknown fields are kept in +raw+.
269
+ class VideoTemplate
270
+ attr_reader :uid, :name, :kind, :width, :height, :fps, :duration_in_frames,
271
+ :poster_url, :status, :variable_definitions, :created_at,
272
+ :updated_at, :raw
273
+
274
+ def initialize(data)
275
+ @raw = data
276
+ @uid = data["uid"]
277
+ @name = data["name"]
278
+ @kind = data["kind"]
279
+ @width = data["width"]
280
+ @height = data["height"]
281
+ @fps = data["fps"]
282
+ @duration_in_frames = data["durationInFrames"]
283
+ @poster_url = data["posterUrl"]
284
+ @status = data["status"]
285
+ @variable_definitions = (data["variableDefinitions"] || []).map do |v|
286
+ TemplateVariableDefinition.new(v)
287
+ end
288
+ @created_at = parse_time(data["createdAt"])
289
+ @updated_at = parse_time(data["updatedAt"])
290
+ end
291
+
292
+ private
293
+
294
+ def parse_time(value)
295
+ return nil unless value
296
+
297
+ Time.parse(value)
298
+ rescue ArgumentError, TypeError
299
+ nil
300
+ end
301
+ end
302
+
303
+ # A video template's variable definitions (+get_video_template_variables+) —
304
+ # what you can set when rendering it.
305
+ #
306
+ # Maps +{ templateUid, templateName, kind, variables: [...], referenced: [...] }+.
307
+ # +referenced+ lists every variable name the document actually references.
308
+ class VideoTemplateVariables
309
+ attr_reader :template_uid, :template_name, :kind, :variables, :referenced, :raw
310
+
311
+ def initialize(data)
312
+ @raw = data
313
+ @template_uid = data["templateUid"]
314
+ @template_name = data["templateName"]
315
+ @kind = data["kind"]
316
+ @variables = (data["variables"] || []).map { |v| TemplateVariableDefinition.new(v) }
317
+ @referenced = data["referenced"] || []
318
+ end
319
+ end
320
+
321
+ # Result of a video template render (+render_video+).
322
+ #
323
+ # Maps +{ url, durationInFrames, format }+. +format+ echoes what was actually
324
+ # produced ("mp4" or "gif").
325
+ class VideoRenderResult
326
+ attr_reader :url, :duration_in_frames, :format, :raw
327
+
328
+ def initialize(data)
329
+ @raw = data
330
+ @url = data["url"]
331
+ @duration_in_frames = data["durationInFrames"]
332
+ @format = data["format"]
333
+ end
334
+ end
335
+
336
+ # Result of AI video template generation (+generate_video_template+).
337
+ #
338
+ # Maps +{ template: {...}, previewUrl }+ — the saved draft {VideoTemplate}
339
+ # plus a rendered preview frame of the generated scene (may be +nil+).
340
+ class GenerateVideoTemplateResult
341
+ attr_reader :template, :preview_url, :raw
342
+
343
+ def initialize(data)
344
+ @raw = data
345
+ @template = VideoTemplate.new(data["template"] || {})
346
+ @preview_url = data["previewUrl"]
347
+ end
348
+ end
256
349
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pictify
4
- VERSION = "1.0.0"
4
+ VERSION = "1.1.0"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pictify
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Pictify
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-08 00:00:00.000000000 Z
11
+ date: 2026-08-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday