SG Ground Truth

media round trip

Take media off one Version and put the same bytes on another, which is what every sync, transfer and hand-off does

API

There is no server-side copy. Nothing moves an upload between two Versions, so a transfer is a download and a fresh three-call upload (probe 013), with the bytes passing through the client.

The renditions

One upload produces up to five assets on five fields, each its own Attachment. On the probed site, a Version whose master was a .jpg:

field data type read as holds name key filename in the signed query
sg_uploaded_movie url object, 6 keys the master, image/jpeg bunny.jpg bunny.jpg
sg_uploaded_movie_mp4 url object, 6 keys the transcode the review player streams, video/mp4 <hash>_bunny.mp4 <hash>_bunny.mp4
sg_uploaded_movie_webm url null never populated on the probed site (probe 022)
sg_uploaded_movie_image url object, 6 keys a poster frame, image/jpg <hash>_bunny_2Dlod.jpeg <hash>_bunny_2Dlod.jpeg
image image bare string the thumbnail absent <hash>_bunny_t.jpg
filmstrip_image image bare string the scrub strip absent <hash>_bunny_filmstrip.jpg

sg_uploaded_movie_frame_rate and sg_uploaded_movie_transcoding_status describe the transcode, not the media, and both survive every clear. Read link_type before url: a local object has no url key at all (field_types/url).

Take the transcode, not the master. sg_uploaded_movie_mp4 is what the review player already streams and the one format the far end opens without a codec question. The master is whatever the artist uploaded: on the probed Version it is a .jpg, so a job that syncs "the movie" from sg_uploaded_movie moves a still image.

Call

import os
import tempfile
from urllib.parse import parse_qs, unquote, urlparse

import requests

# get/put/post are FPT.get/.put/.post from src/sg_groundtruth/client.py; they add auth and the /api/v1 prefix.
# The caller supplies SOURCE_ID and TARGET_ID.
JSON = {"Content-Type": "application/json"}
RENDITIONS = ["sg_uploaded_movie", "sg_uploaded_movie_mp4", "sg_uploaded_movie_webm",
              "sg_uploaded_movie_image", "image", "filmstrip_image"]
PREFER = ["sg_uploaded_movie_mp4", "sg_uploaded_movie_webm", "sg_uploaded_movie_image",
          "sg_uploaded_movie", "image"]


def read(version_id, fields):
    r = get(f"/entity/versions/{version_id}", params={"fields": ",".join(fields)})
    return r.json()["data"]["attributes"]


def rendition_url(value):
    """image is a bare string, a url field is an object, and a link_type local object has no url."""
    if isinstance(value, str):
        return None if "/images/status/transient/" in value else value      # still transcoding
    if isinstance(value, dict) and value.get("link_type") != "local":
        return value.get("url")
    return None


def filename_of(url, value):
    """The signature signs response-content-disposition, so the filename is in the query string.
    An image field has no name key, so that query parameter is the only place its extension exists."""
    cd = (parse_qs(urlparse(url).query).get("response-content-disposition") or [""])[0]
    for part in cd.split(";"):
        part = part.strip()
        for key in ("filename*=UTF-8''", "filename="):
            if part.startswith(key):
                return unquote(part[len(key):].strip('"'))
    return value.get("name") if isinstance(value, dict) else None


# 1. read the source and pick the transcode over the master
a = read(SOURCE_ID, RENDITIONS)
field = next(f for f in PREFER if rendition_url(a.get(f)))
url = rendition_url(a[field])
filename = filename_of(url, a[field])

# 2. download. The url is signed for X-Amz-Expires seconds from X-Amz-Date; re-read the field for a
#    fresh one instead of caching the string. GET only: the signature covers no other method.
fd, tmp = tempfile.mkstemp(prefix="fpt_media_")
os.close(fd)
try:
    r = requests.get(url, timeout=300)
    r.raise_for_status()
    with open(tmp, "wb") as fh:
        fh.write(r.content)

    # 3. clear all six renditions on the target by name. Clearing sg_uploaded_movie alone leaves the
    #    other five serving the file you replaced.
    put(f"/entity/versions/{TARGET_ID}", headers=JSON, json={f: None for f in RENDITIONS})

    # 4. upload: init, PUT the bytes to the presigned link, complete (probe 013)
    b = get(f"/entity/versions/{TARGET_ID}/sg_uploaded_movie/_upload",
            params={"filename": filename}).json()
    with open(tmp, "rb") as fh:
        requests.put(b["links"]["upload"], data=fh.read(), timeout=300)      # presigned S3
    post(b["links"]["complete_upload"], headers=JSON,                        # upload_data required, empty
         json={"upload_info": b["data"], "upload_data": {}})
finally:
    os.unlink(tmp)          # the temp file goes even when the upload raises

Poll sg_uploaded_movie_transcoding_status until it leaves 0 if the far end needs the mp4.

Response

1. source Version, 5 of 6 renditions filled, transcoding_status 1
   chosen sg_uploaded_movie_mp4, Attachment 1431, video/mp4

2. the signed url, query parameters:
     X-Amz-Algorithm X-Amz-Credential X-Amz-Date X-Amz-Expires X-Amz-Security-Token
     X-Amz-Signature X-Amz-SignedHeaders response-content-disposition
     x-amz-meta-user-id x-amz-meta-user-type
   read 1  X-Amz-Date=20260902T192216Z  X-Amz-Expires=847
   read 2  X-Amz-Date=20260902T192216Z  X-Amz-Expires=900   different string, one second later
   HEAD -> 403 application/xml          GET -> 200 video/mp4 158170 bytes

3. target Version, seeded with a 16x16 png so a re-sync has stale state to fix
     after ~40s  transcoding_status=2, every derived field null: the png was refused
   first sync, the 158170-byte mp4:
     after ~40s  transcoding_status=1, _mp4 <hash2>_<hash>_bunny.mp4, image and filmstrip_image set

4. PUT {"sg_uploaded_movie": null}                    -> 200
     sg_uploaded_movie null; _mp4, image, filmstrip_image still serving the old file
     frame_rate '25.0'   transcoding_status 1
   PUT all six null                                   -> 200
     all six null; frame_rate '25.0'   transcoding_status 1   still stale

5. upload the same bytes back                         -> PUT 200, complete 201
   immediately: sg_uploaded_movie set, _mp4 null, image /images/status/transient/,
                transcoding_status 0, frame_rate still '25.0' from the previous file
   after ~40s:  transcoding_status 1, _mp4 <hash3>_<hash>_bunny.mp4 (a second transcode of a transcode),
                sg_uploaded_movie_image still null
   GET what came back -> 200 binary/octet-stream 158170 bytes, byte-identical to what was sent

6. Attachments linked to the target: 5, for one file synced twice

Notes

  • No server-side copy, and no reference to reuse. The only value sg_uploaded_movie accepts is an object holding a url (field_types/url). Wrapping the source's presigned url in {"url": …, "name": …} answers 200 and stores a link_type: web link that dies with the signature, with no transcode and no thumbnail. Moving the bytes is the only transfer that survives.

  • The signature expires, and re-reading the field is the fix. The window is X-Amz-Expires seconds from X-Amz-Date, and the number is not a constant: two reads one second apart returned 847 and 900.

  • Both reads returned different strings for the same Attachment, so a client that outlives its url re-reads the field and starts the transfer again rather than retrying the string. A string held 706 seconds past expiry 403s AccessDenied (field_types/image). Persist the Attachment id or the Version id; never the url.

  • HEAD 403s with an application/xml body. The signature covers GET alone, so size and type come from the GET response or from GET /entity/attachments/{id}.

  • Where the extension lives depends on the field.

    field source of the filename
    sg_uploaded_movie and the three derived url fields name, and response-content-disposition agrees with it
    image, filmstrip_image response-content-disposition only: an image field is a bare string with no name

    Parse the query parameter in both cases and the same code handles all six. Uploading with the wrong extension is accepted, so nothing downstream corrects it.

  • Clearing is per field, and two readings never clear.

    after sg_uploaded_movie _mp4 image, filmstrip_image _frame_rate _transcoding_status
    PUT {"sg_uploaded_movie": null} null old file old file '25.0' 1
    PUT all six null null null null '25.0' 1
    the new upload, before the transcode new file null /images/status/transient/ '25.0' 0
    the new upload, after the transcode new file new file new file '25.0' 1

    _frame_rate and _transcoding_status are a float and a number, not url fields, and neither null nor the upload resets them. Between the clear and the transcode landing they describe a file the Version no longer holds, exactly as a replacement does (probe 022). _frame_rate reads back as a JSON string (field_types/float).

  • sg_uploaded_movie_transcoding_status was 0 in flight, 1 after the transcode landed, and 2 for a 16x16 png the transcoder refused, which left every derived field null. Treat 1 as "a transcode finished", never as "this media is transcoded": 1 was the reading throughout the clear, when the Version held no media at all.

  • The target does not end up with the source's rendition set. Uploading the source's mp4 produced a second transcode of an already transcoded file (<hash3>_<hash>_bunny.mp4), a thumbnail and a filmstrip, and left sg_uploaded_movie_image null, which the source had. Compare Versions on the file you sent, not on which fields are filled.

  • Attachments accumulate and the clear does not touch them. One file synced twice left 5 Attachments on the target: the seed, both uploads and both transcodes. PUT … null unlinks nothing; DELETE /entity/attachments/{id} does, and only the rows you made.

  • The download's Content-Type is not the media's. Reading the round-tripped file back served binary/octet-stream while the field reads video/mp4. Trust the field's content_type.

  • Wrap the download so the temp file is removed even when the upload raises. A failed sync that keeps its scratch file fills the disk of whatever runs the job.

Every entry on this site is the output of a probe in probes/. The corpus is generated by running those probes against a live Flow Production Tracking site, not written from memory.

Not affiliated with or endorsed by Autodesk. Flow Production Tracking is their product; this is an independent record of how its REST API answers.