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
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_movieaccepts is an object holding aurl(field_types/url). Wrapping the source's presigned url in{"url": …, "name": …}answers 200 and stores alink_type: weblink 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-Expiresseconds fromX-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.HEAD403s with anapplication/xmlbody. The signature coversGETalone, so size and type come from theGETresponse or fromGET /entity/attachments/{id}.Where the extension lives depends on the field.
field source of the filename sg_uploaded_movieand the three derivedurlfieldsname, andresponse-content-dispositionagrees with itimage,filmstrip_imageresponse-content-dispositiononly: animagefield is a bare string with nonameParse 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_mp4image,filmstrip_image_frame_rate_transcoding_statusPUT {"sg_uploaded_movie": null}null old file old file '25.0'1 PUTall six nullnull 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_rateand_transcoding_statusare afloatand anumber, noturlfields, and neithernullnor 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_ratereads back as a JSON string (field_types/float).sg_uploaded_movie_transcoding_statuswas 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 leftsg_uploaded_movie_imagenull, 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 … nullunlinks nothing;DELETE /entity/attachments/{id}does, and only the rows you made.The download's
Content-Typeis not the media's. Reading the round-tripped file back servedbinary/octet-streamwhile the field readsvideo/mp4. Trust the field'scontent_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.